diff --git a/README.md b/README.md index 9487008b..c3b25b97 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,298 @@ -# Clean Architecture Team Lab Activity: Login and Logout +# Stock Project -In this team lab activity, your team will: -- explore an existing use case (login) -- add a new use case (logout). +A comprehensive stock market application built with Java Swing, following Clean Architecture principles. This application provides real-time stock data, news, earnings history, and market status information. -To earn credit: -- your team must demo your working `logout` use case. +## Features -Your demo should be similar to the below example: +### User Management +- **User Registration**: Create a new account with username and password +- **User Login**: Secure authentication to access the application +- **Password Management**: Change password functionality +- **User Logout**: Secure session termination -![](images/sample-logout.gif) +### Stock Information +- **Stock Search**: Search for stocks by symbol with detailed information +- **Filter Search**: Advanced filtering capabilities for stock discovery +- **Real-Time Trade Feed**: Live WebSocket connection to receive real-time trade data + - Connect to any trading symbol (e.g., `BINANCE:BTCUSDT`, `AAPL`, `TSLA`) + - View real-time price, volume, and timestamp updates + - Rate limiting protection to prevent API throttling + - Automatic error handling for invalid symbols or connection issues ---- +### Market Data +- **Market News**: View latest market and company-specific news articles +- **Earnings History**: Access historical earnings data for companies +- **Market Status**: Real-time market open/closed status indicator -## Task 0: Fork this repo on GitHub -**To get started, one team member should fork this repo on GitHub and share it with the team. -All team members should then clone it.** +## Architecture +This project follows **Clean Architecture** principles with clear separation of concerns: -**Suggested logistics:** One of you should invite the others to collaborate on their fork of the original repo on GitHub. You can do this in your repo on GitHub under `Settings -> Collaborators`. This will allow you to push branches to a common repo and then use pull requests to contribute your code and review. To prevent others from pushing directly to the main branch, we recommend that you set branch protection rules on GitHub. Below are how the settings might look if you add branch protection rules: - -![image of branch protection rules for main with a requirement of two approvers to merge in pull requests.](images/branch_protection_rules.png) - ---- - -## Task 1: Understanding the Program - -Open the project in IntelliJ. Open `app.Main` and read it as a team. -- What are the currently implemented Views and Use Cases in the program? -- Which Use Cases are triggered from each View? -- Which version of the DAO is `app.Main` using? - -> Observe that the main method makes use of the `app.AppBuilder` class which -is responsible for constructing our CA engine for each use case of the application. To answer the last two questions above, you will need to look inside the details of the `app.AppBuilder` class. - -**Make sure that each member of your team can successfully run `app/Main.java`.** -- Ensure that you are each able to create a new user and log in using the username and password. - -> Note: you may need to set the Project SDK in the `Project Structure...` menu, and possibly -> also manually link the Maven project if the app won't run when you try to run Main. - -### Task 1.1: Exploring the login use case - -Let's take a tour of the login use case code: - -- In IntelliJ, find the `LoginController` class and open it. - -- Set a breakpoint inside its `execute` method. - -- Run the program in debug mode. - -- On the login page, attempt to log in with an existing account. When you click the button, the breakpoint that you set will be triggered. - -- **Step through the code to trace the execution of the login use case.** - Importantly, pay extra close attention to what the Presenter does to ensure that the LoggedInView gets displayed after the user successfully gets logged into the application. - -The code is designed based on our CA Engine that was introduced in the reading this week. For reference, here is our CA Engine diagram: - -![The Clean Architecture Engine diagram](images/CA-Engine.png) - -> Pay attention to the classes involved and the flow of execution. When your team implements the logout use case next, your code will need to have a very similar structure. - -To better understand how the view gets updated, your team may find it useful to review the [Extra Advice about the Presenters, Views, and ViewModels](#extra-advice-about-the-presenters-views-and-viewmodels) section at the end of this README. - -## Task 2: Implementing the Logout Use Case - -Currently, you'll notice that the "Log Out" button in the `LoggedInView` still doesn't actually log you out of the program. Let's fix this. - -We have created all the classes for your team, but some of the code is missing. **As a team, your task is to fill in the missing code so that the logout use case is functional.** - -> The next part of the readme describes how your team will do this. - -Your team will know when you are done when: - -- Clicking the "Log Out" button takes the user back to the Login View when you use the program. -- On the Login View, the username of the logged-out user is filled in. -- The provided `LogoutInteractorTest` test passes. - -### Task 2.1: Dividing up the work - -There are `TODO` comments left in the files. - -> Recall that you can use the TODO tool window to conveniently pull up a complete list. - -Once all TODOs are complete, the "Log Out" button _should_ work! - -**As a team, split up the TODOs (see below) between the members of your team.** - -> Optionally, your team can make GitHub Issues and assign them to each team member. - -Make sure that each member has at least one TODO that they will be responsible for completing. -If your team prefers to work in pairs, that is fine too. - -The TODOs are summarized below (by file) to help your team decide how to split them up: - ---- - -- `Main.java` (tip: look at how other use cases have been added) - -[ ] TODO: add the logout use case to the app - ---- - -- `LoggedInView.java` (tip: refer to the other views for similar code) - -[ ] TODO: save the logout controller in the instance variable. - -[ ] TODO: execute the logout use case through the Controller - ---- - -- `LogoutController.java` (tip: refer to the other controllers for similar code) - -[ ] TODO: Save the interactor in the instance variable. - -[ ] TODO: run the use case interactor for the logout use case - -> Note: there is no input data necessary for this use case. - ---- - -- `LogoutInteractor.java` (tip: refer to `ChangePasswordInteractor.java` for similar code) - -[ ] TODO: save the DAO and Presenter in the instance variables. - -[ ] TODO: implement the logic of the Logout Use Case - -> Note: there is no input data necessary for this use case. - ---- - -- `LogoutPresenter.java` (tip: refer to `SignupPresenter.java` for similar code) - -[ ] TODO: assign to the three instance variables. - -[ ] TODO: have prepareSuccessView update the LoggedInState - -[ ] TODO: have prepareSuccessView update the LoginState - ---- - -### Task 2.2: Complete your TODOs! -With the work divided up, your team should complete the TODOs through a sequence of PRs. - -1. Make a branch for your work. - -> Make sure that you switch to your new branch! - -2. Complete your assigned TODO and make a pull request on GitHub. In your pull request, - briefly describe what your TODO was and how you implemented it. If you aren't sure - about part of it, include this in your pull request so that everyone knows what to look - for when reviewing — or you can of course discuss with your team before making your - pull request. - -3. Review all pull requests to ensure each TODO is correctly implemented. - -4. Once all TODOs are completed, your team should debug as needed to ensure the - correctness of the code. Setting a breakpoint where the logout use case - interactor starts its work will likely be a great place to start when debugging. - -And that's it; your team should now have a working logout use case! - -**Demo your working code to your TA to earn credit.** - ---- - -# Extra Advice about the Presenters, Views, and ViewModels - -One of the trickiest parts of the code will be the flow of information between these pieces of the program. Below briefly explains how these pieces fit together and work in the context of the login use case. - -## ViewModels and States - -In the design of this program, `ViewModel` is written using generics to allow -for different "state" objects to be stored. For a `LoginViewModel`, the state is an instance of class `LoginState`. Each state object will just contain a basic constructor, getters, and setters to store the data of the view model. - -## A View and its ViewModel - -In the constructor of `LoginView`, the following line of code connects this instance of `LoginView` to its associated `LoginViewModel`: - -```java -this.loginViewModel.addPropertyChangeListener(this); ``` - -This should remind you of the code we write when adding an action listener to a button. The code is following the same structure. - -> We'll talk more about this "pattern" of _events_ and _listeners_ in our next module. - -When the presenter updates the view model later, an event will be triggered — resulting in the view's `propertyChange` method getting called, with a `PropertyChangeEvent` object being passed through as the argument to the call. - -For example, the `LoginView.propertyChange` method looks like: - -```java -public void propertyChange(PropertyChangeEvent evt) { - final LoginState state = (LoginState) evt.getNewValue(); - setFields(state); - usernameErrorField.setText(state.getLoginError()); - } +src/main/java/ +├── app/ # Application entry point and dependency injection +├── data_access/ # Infrastructure layer (API clients, file I/O) +├── entity/ # Domain models +├── interface_adapter/ # Adapters (ViewModels, Controllers, Presenters) +├── use_case/ # Business logic (Interactors) +└── view/ # Presentation layer (Swing UI) ``` -The `LoginView` gets the `LoginState` object stored in the `LoginViewModel` and updates itself with that information. - -## A Presenter and its ViewModel(s) +### Key Components + +- **Entity Layer**: Domain models (`User`, `Stock`, `StockQuote`, `Trade`, `NewsArticle`, `EarningsRecord`, `MarketStatus`) +- **Use Case Layer**: Business logic and application rules + - User management: Login, Signup, Logout, Change Password + - Stock operations: Stock Search, Filter Search + - Market data: News, Earnings History, Market Status + - Real-time data: Trade Feed + - User features: Watchlist management +- **Interface Adapter Layer**: Controllers, Presenters, and ViewModels +- **Framework Layer**: Data access objects and UI components (Swing views) + +## Getting Started + +### Prerequisites +- Java 15 or higher +- Maven 3.6+ +- Internet connection (for API access) + +### Installation & Running + +1. **Clone the repository**: + ```bash + git clone + cd CSC207_Stock_Project + ``` + +2. **Build the project**: + ```bash + mvn clean compile + ``` + +3. **Run the application**: + ```bash + mvn exec:java -Dexec.mainClass="app.Main" + ``` + Or run `Main.java` directly from your IDE + +4. **Run tests**: + ```bash + mvn test + ``` + +### First Time Setup + +1. **Create an Account**: + - Launch the application + - Click "Sign Up" to create a new user account + - Enter a username and password + +2. **Login**: + - Use your credentials to log in + +3. **Explore Features**: + - Navigate using the top toolbar buttons + - Try searching for stocks, viewing news, or connecting to real-time trades + +## Usage Guide + +### User Management + +#### Sign Up +1. Launch the application +2. Enter a username and password +3. Click "Sign Up" to create your account +4. You'll be automatically redirected to the login page + +#### Login +1. Enter your username and password +2. Click "Log In" to access the application +3. You'll be taken to the main dashboard (Logged In View) + +#### Change Password +1. After logging in, enter your new password in the password field +2. Click "Change Password" to update your credentials +3. You'll receive a confirmation message upon success + +#### Logout +1. Click "Log Out" to securely end your session +2. You'll be redirected to the login page + +### Main Dashboard Features + +Once logged in, you'll see the main dashboard with the following features: + +#### Stock Search & Watchlist +1. Enter a stock symbol (e.g., `AAPL`, `TSLA`, `GOOGL`) in the search field on the main dashboard +2. Click "Search" to retrieve detailed stock information +3. View stock details in the "Stock Information" panel including: + - Company name and sector + - Current price and market data + - Price changes and volume +4. **Add to Watchlist**: Click the "Add to Watchlist" button at the bottom of the main dashboard to save the currently displayed stock information to your watchlist +5. **View Watchlist**: Click the "Account" button in the top toolbar to view all your saved watchlist items + +#### Real-Time Trade Feed +1. Click the **"Realtime Trade"** button in the top toolbar +2. Enter a trading symbol in the input field (default: `BINANCE:BTCUSDT`) + - **Crypto pairs work best**: `BINANCE:BTCUSDT`, `BINANCE:ETHUSDT` + - **Stock symbols**: `AAPL`, `TSLA` (only active during market hours) +3. Click **"Connect"** to start receiving real-time trade data +4. View live updates for: + - Symbol + - Last Price + - Volume + - Timestamp +5. Click **"Disconnect"** to stop the feed before connecting to a new symbol + +**Important Notes:** +- You must disconnect before connecting to a new symbol +- Rate limiting is enforced (5-second cooldown between connections) +- Invalid symbols or connection errors will display appropriate error messages +- The application automatically handles API rate limits (429 errors) +- Crypto pairs (e.g., `BINANCE:BTCUSDT`) work 24/7, while stock symbols only work during market hours + +#### Filter Search +1. Click the **"Filter Search"** button in the top toolbar +2. Use the search and filter options to find stocks by: + - Symbol pattern + - Market criteria + - Other filtering options +3. View filtered results in the table +4. Click "Back" to return to the main dashboard + +#### News +1. Click the **"News"** button in the top toolbar +2. View latest market news and company-specific articles +3. Filter news by company symbol if needed +4. Click "Back" to return to the main dashboard + +#### Earnings History +1. Click the **"History"** button in the top toolbar +2. Enter a stock symbol to view historical earnings data +3. Browse through past earnings records and dates +4. Click "Back" to return to the main dashboard + +#### Account & Watchlist +1. Click the **"Account"** button in the top toolbar +2. View all your saved watchlist items that you've added from the main dashboard +3. Your watchlist displays all the stock information you've saved using the "Add to Watchlist" button +4. Click "Back" to return to the main dashboard + +#### Market Status +- The market status indicator is displayed in the top toolbar +- Shows current market status (Open/Closed) in real-time +- Updates automatically when market status changes +- Color-coded: Green for open, Gray for closed, Red for errors + +## Technical Details + +### Dependencies +- **OkHttp 4.12.0**: For HTTP and WebSocket connections +- **JSON 20240303**: For JSON parsing +- **JUnit 5.8.1**: For testing (test scope) + +### API Integration +- **Finnhub API**: Used for stock data, news, earnings, and real-time trade feeds +- WebSocket connections for real-time data streaming +- REST API for historical and static data + +### Data Storage +- User data stored in `users.csv` (file-based storage) +- Alternative database storage option available (`DBUserDataAccessObject`) + +## Project Structure -A presenter may have one or more view models associated with it. For example, the login use case's presenter has a reference to a `LoginViewModel` and a `LoggedInViewModel`, since it will need to update both view models. Additionally, our implementation makes use of a `ViewManager` and `ViewManagerModel` to keep track of which view -the user should currently see. +``` +CSC207_Stock_Project/ +├── src/ +│ ├── main/java/ +│ │ ├── app/ # Application setup +│ │ ├── data_access/ # API clients and data sources +│ │ ├── entity/ # Domain models +│ │ ├── interface_adapter/# Adapters +│ │ ├── use_case/ # Business logic +│ │ └── view/ # UI components +│ └── test/java/ # Unit tests +├── pom.xml # Maven configuration +└── README.md # This file +``` -Let's take a look at the `LoginPresenter.prepareSuccessView` method as an example: +## Error Handling -```java -public void prepareSuccessView(LoginOutputData response) { - // On success, update the loggedInViewModel's state - final LoggedInState loggedInState = loggedInViewModel.getState(); - loggedInState.setUsername(response.getUsername()); - this.loggedInViewModel.firePropertyChanged(); +The application includes comprehensive error handling across all features: +- **User Authentication**: Clear error messages for invalid credentials or existing usernames +- **Password Management**: Validation for password changes and mismatches +- **Stock Search**: Error messages for invalid symbols or search failures +- **Rate Limiting**: Automatic cooldown periods to prevent API throttling (trade feed) +- **Connection Errors**: User-friendly error messages for WebSocket connection issues +- **Invalid Symbols**: Clear feedback when symbols are not found or unavailable +- **Network Issues**: Graceful handling of network failures and API unavailability +- **Data Loading**: Error handling for failed data fetches (news, earnings, market status) - // and clear everything from the LoginViewModel's state - this.loginViewModel.setState(new LoginState()); - this.loginViewModel.firePropertyChanged(); +## Use Cases Implemented - // switch to the logged in view - this.viewManagerModel.setState(loggedInViewModel.getViewName()); - this.viewManagerModel.firePropertyChanged(); -} -``` +The application implements the following use cases, each following Clean Architecture: -The first part of the code updates the view model for the logged-in view so that the newly logged-in username will be displayed. Once the state is updated, the `firePropertyChanged` method is called, which is what will trigger the call to the view's `propertyChange` method which will update the view based on the updated view model. +### User Management +- **Signup**: Create new user accounts with validation +- **Login**: Authenticate users and manage sessions +- **Logout**: Terminate user sessions securely +- **Change Password**: Update user passwords with validation -This can be visualized as a sequence diagram as follows: +### Stock Operations +- **Stock Search**: Search for stocks by symbol and display detailed information +- **Filter Search**: Advanced filtering and search capabilities for stock discovery -> Note: this diagram has been simplified to focus on the high-level flow of information; the actual stack trace includes some additional intermediate calls which you can see if you step through the code in the debugger or manually click through the code. +### Market Data +- **News**: Fetch and display market and company-specific news articles +- **Earnings History**: Retrieve and display historical earnings data for companies +- **Market Status**: Real-time market open/closed status with automatic updates -![sequence diagram of the LoginPresenter code](images/login_presenter_sequence_diagram.png) +### Real-Time Features +- **Trade Feed**: WebSocket-based real-time trade data streaming + - Supports both crypto pairs and stock symbols + - Automatic reconnection and error handling + - Rate limiting protection -We then do the same, but for the login view model whose state we want to clear. +### User Features +- **Watchlist**: Save and manage favorite stocks in user accounts +- **Account Management**: View and manage user account information -Lastly, we update the state of the `viewManagerModel`, and alert the viewManager that it should switch to displaying the logged-in view. +## Design Patterns & Principles -> Setting a breakpoint in the code and stepping through can help you see how the information flows through the system. Pay attention to the contents of the call stack to help you track where you are in the execution of the use case. +### SOLID Principles +- **Single Responsibility**: Each class has one clear purpose +- **Open/Closed**: System open for extension (new implementations) but closed for modification +- **Liskov Substitution**: All interface implementations are interchangeable +- **Interface Segregation**: Focused interfaces (e.g., separate interfaces for Signup, Login, ChangePassword) +- **Dependency Inversion**: High-level components depend on abstractions (interfaces), not concrete implementations -## The ViewManager +### Design Patterns +- **Observer Pattern**: PropertyChangeListener for view updates +- **Adapter Pattern**: TradeListener adapts async callbacks to synchronous presenter calls +- **Builder Pattern**: AppBuilder for dependency injection and application setup +- **Strategy Pattern**: Swappable data access implementations (FileUserDataAccessObject, DBUserDataAccessObject) -This class may stand out as a bit unclear about how it fits into our architecture, as it isn't in the CA Engine diagram at all. Remember that the CA Engine is representing a single use case in our program. Once our program has _multiple_ use cases, we naturally need some kind of additional code to connect them together. As we have seen, one use case can lead to a change in which view is presented to the user. To facilitate this, our implementation used a `ViewManager` and associated `ViewManagerModel` to take care of this switching for us. The state of a `ViewManagerModel` object is simply a string that indicates the name of the currently visible view (`JPanel` in this implementation). The `ViewManager` uses a `CardLayout` to conveniently display only the currently active view at a given time. +## Testing -When the `ViewManager` is alerted of a change to its associated `ViewManagerModel`, its `propertyChange` method is executed: +The project includes unit tests for use cases: +- Login, Signup, Logout tests +- Stock Search, Filter Search tests +- News, Earnings History, Market Status tests +- Additional tests can be run with `mvn test` -```java -public void propertyChange(PropertyChangeEvent evt) { - if (evt.getPropertyName().equals("state")) { - final String viewModelName = (String) evt.getNewValue(); - cardLayout.show(views, viewModelName); - } - } -``` +## Contributing -This code will update the application to display the view corresponding to the `viewModelName` string. +This is a course project following Clean Architecture principles. When contributing: +- Maintain separation of concerns across layers +- Follow the existing architecture patterns +- Add appropriate error handling +- Include unit tests for new features +- Respect interface boundaries and dependency inversion -> In the `AppBuilder` code, you can see how the views are originally added to the `cardLayout`. +## License -> Thought Question: Can you think of any alternatives to our `ViewManager` implementation for managing multiple views? +[Add your license information here if applicable] ---- diff --git a/plantuml/login_presenter_sequence_diagram.puml b/plantuml/login_presenter_sequence_diagram.puml deleted file mode 100644 index 0dd0b200..00000000 --- a/plantuml/login_presenter_sequence_diagram.puml +++ /dev/null @@ -1,39 +0,0 @@ -@startuml -skinparam dpi 300 -actor User -participant LoginPresenter as LP -participant LoggedInViewModel as LVM -participant LoggedInState as LS -participant PropertyChangeSupport as PCS -participant LoggedInView as LIV - -activate LP -User -> LP : prepareSuccessView(LoginOutputData) -LP -> LVM : getState() -activate LVM -LVM --> LP : LoggedInState -deactivate LVM - -LP -> LS : setUsername(response.getUsername()) -activate LS -LS --> LP -deactivate LS - -LP -> PCS : firePropertyChange() -activate PCS - - -PCS -> LIV : propertyChange(evt) -activate LIV - -LIV -> LIV : update UI with LoggedInState - -LIV --> PCS -deactivate LIV -PCS --> LP -deactivate PCS - -... Diagram continues with LoginViewModel and ViewManager updates... - - -@enduml \ No newline at end of file diff --git a/src/main/java/app/AppBuilder.java b/src/main/java/app/AppBuilder.java index 3c654cad..d57b15fd 100644 --- a/src/main/java/app/AppBuilder.java +++ b/src/main/java/app/AppBuilder.java @@ -1,8 +1,34 @@ package app; +import data_access.FinnhubEarningsDataAccessObject; +import interface_adapter.account.AccountController; +import interface_adapter.account.AccountPresenter; +import interface_adapter.account.AccountViewModel; +import interface_adapter.earnings_history.EarningsHistoryController; +import interface_adapter.earnings_history.EarningsHistoryPresenter; +import interface_adapter.earnings_history.EarningsHistoryViewModel; +import use_case.earnings_history.EarningsDataAccessInterface; +import use_case.earnings_history.GetEarningsHistoryInputBoundary; +import use_case.earnings_history.GetEarningsHistoryInteractor; +import use_case.earnings_history.GetEarningsHistoryOutputBoundary; +import use_case.watchlist.WatchlistInputBoundary; +import use_case.watchlist.WatchlistInteractor; +import use_case.watchlist.WatchlistOutputBoundary; +import use_case.watchlist.WatchlistUserDataAccessInterface; +import view.EarningsHistoryView; + import data_access.FileUserDataAccessObject; +import data_access.FilterSearchDataAccessObject; +import data_access.stock_search.FinnhubStockSearchDataAccessObject; +import data_access.NewsDataAccessObject; import entity.UserFactory; import interface_adapter.ViewManagerModel; +import interface_adapter.filter_search.FilterSearchController; +import interface_adapter.filter_search.FilterSearchPresenter; +import interface_adapter.filter_search.FilterSearchViewModel; +import interface_adapter.stock_search.StockSearchController; +import interface_adapter.stock_search.StockSearchPresenter; +import interface_adapter.stock_search.StockSearchViewModel; import interface_adapter.logged_in.ChangePasswordController; import interface_adapter.logged_in.ChangePasswordPresenter; import interface_adapter.logged_in.LoggedInViewModel; @@ -11,25 +37,52 @@ import interface_adapter.login.LoginViewModel; import interface_adapter.logout.LogoutController; import interface_adapter.logout.LogoutPresenter; +import interface_adapter.news.NewsController; +import interface_adapter.news.NewsPresenter; +import interface_adapter.news.NewsViewModel; import interface_adapter.signup.SignupController; import interface_adapter.signup.SignupPresenter; import interface_adapter.signup.SignupViewModel; import use_case.change_password.ChangePasswordInputBoundary; import use_case.change_password.ChangePasswordInteractor; import use_case.change_password.ChangePasswordOutputBoundary; +import use_case.filter_search.FilterSearchDataAccessInterface; +import use_case.filter_search.FilterSearchInputBoundary; +import use_case.filter_search.FilterSearchInteractor; +import use_case.filter_search.FilterSearchOutputBoundary; +import use_case.stock_search.StockSearchDataAccessInterface; +import use_case.stock_search.StockSearchInputBoundary; +import use_case.stock_search.StockSearchInteractor; +import use_case.stock_search.StockSearchOutputBoundary; import use_case.login.LoginInputBoundary; import use_case.login.LoginInteractor; import use_case.login.LoginOutputBoundary; import use_case.logout.LogoutInputBoundary; import use_case.logout.LogoutInteractor; import use_case.logout.LogoutOutputBoundary; +import use_case.news.NewsInputBoundary; +import use_case.news.NewsInteractor; +import use_case.news.NewsOutputBoundary; import use_case.signup.SignupInputBoundary; import use_case.signup.SignupInteractor; import use_case.signup.SignupOutputBoundary; -import view.LoggedInView; -import view.LoginView; -import view.SignupView; -import view.ViewManager; +import interface_adapter.market_status.MarketStatusViewModel; +import interface_adapter.market_status.MarketStatusPresenter; +import interface_adapter.market_status.MarketStatusController; +import use_case.market_status.MarketStatusInputBoundary; +import use_case.market_status.MarketStatusInteractor; +import use_case.market_status.MarketStatusOutputBoundary; +import use_case.market_status.MarketStatusDataAccessInterface; +import data_access.MarketStatusDataAccessObject; +import data_access.FinnhubTradeDataAccessObject; +import use_case.trade.TradeDataAccessInterface; +import use_case.trade.TradeInputBoundary; +import use_case.trade.TradeInteractor; +import use_case.trade.TradeOutputBoundary; +import interface_adapter.trade.TradeController; +import interface_adapter.trade.TradePresenter; +import interface_adapter.trade.TradeViewModel; +import view.*; import javax.swing.*; import java.awt.*; @@ -44,8 +97,22 @@ public class AppBuilder { // set which data access implementation to use, can be any // of the classes from the data_access package + // This key should be retrieved from the environment + String apiKey = "d4lpdgpr01qr851prp30d4lpdgpr01qr851prp3g"; + // DAO version using local file storage final FileUserDataAccessObject userDataAccessObject = new FileUserDataAccessObject("users.csv", userFactory); + // DAO for earnings history + final EarningsDataAccessInterface earningsDataAccessObject = + new FinnhubEarningsDataAccessObject(); + final WatchlistUserDataAccessInterface watchlistDataAccessObject = userDataAccessObject; + final FilterSearchDataAccessInterface filterSearchDataAccessObject = + new FilterSearchDataAccessObject(apiKey); + final StockSearchDataAccessInterface stockSearchDataAccessObject = + new FinnhubStockSearchDataAccessObject(apiKey); + final NewsDataAccessObject newsDataAccessObject = new NewsDataAccessObject(apiKey); + final MarketStatusDataAccessInterface marketStatusDataAccessObject = + new MarketStatusDataAccessObject(apiKey); // DAO version using a shared external database // final DBUserDataAccessObject userDataAccessObject = new DBUserDataAccessObject(userFactory); @@ -56,6 +123,21 @@ public class AppBuilder { private LoggedInViewModel loggedInViewModel; private LoggedInView loggedInView; private LoginView loginView; + private FilterSearchViewModel filterSearchViewModel; + private FilterSearchView filterSearchView; + private StockSearchViewModel stockSearchViewModel; + private StockSearchController stockSearchController; + private NewsViewModel newsViewModel; + private NewsView newsView; + private EarningsHistoryViewModel earningsHistoryViewModel; + private EarningsHistoryView earningsHistoryView; + private AccountView accountView; + private AccountViewModel accountViewModel; + private TradeView tradeView; + private TradeViewModel tradeViewModel; + + private MarketStatusViewModel marketStatusViewModel; + private MarketStatusController marketStatusController; public AppBuilder() { cardPanel.setLayout(cardLayout); @@ -82,6 +164,59 @@ public AppBuilder addLoggedInView() { return this; } + public AppBuilder addFilterSearchView() { + filterSearchViewModel = new FilterSearchViewModel(); + filterSearchView = new FilterSearchView(filterSearchViewModel); + cardPanel.add(filterSearchView, filterSearchView.getViewName()); + return this; + } + + public AppBuilder addNewsView() { + newsViewModel = new NewsViewModel(); + NewsOutputBoundary newsOutputBoundary = new NewsPresenter(newsViewModel); + NewsInputBoundary newsInputBoundary = + new NewsInteractor(newsDataAccessObject, newsOutputBoundary); + NewsController newsController = new NewsController(newsInputBoundary); + + newsView = new NewsView(newsController, newsViewModel); + cardPanel.add(newsView, newsView.getViewName()); + return this; + } + public AppBuilder addEarningsHistoryView() { + // ViewModel + earningsHistoryViewModel = new EarningsHistoryViewModel(); + + // Presenter + interactor + GetEarningsHistoryOutputBoundary outputBoundary = + new EarningsHistoryPresenter(earningsHistoryViewModel); + GetEarningsHistoryInputBoundary interactor = + new GetEarningsHistoryInteractor(earningsDataAccessObject, outputBoundary); + + // Controller + EarningsHistoryController controller = + new EarningsHistoryController(interactor, earningsHistoryViewModel); + + // Swing view + earningsHistoryView = new EarningsHistoryView(controller, earningsHistoryViewModel); + + cardPanel.add(earningsHistoryView, earningsHistoryView.getViewName()); + return this; + } + + public AppBuilder addTradeView() { + tradeViewModel = new TradeViewModel(); + + TradeOutputBoundary tradeOutputBoundary = new TradePresenter(tradeViewModel); + TradeDataAccessInterface tradeDataAccess = new FinnhubTradeDataAccessObject(); + TradeInputBoundary tradeInteractor = new TradeInteractor(tradeDataAccess, tradeOutputBoundary); + TradeController tradeController = new TradeController(tradeInteractor); + + + tradeView = new TradeView(tradeController, tradeViewModel); + cardPanel.add(tradeView, tradeView.getViewName()); + return this; + } + public AppBuilder addSignupUseCase() { final SignupOutputBoundary signupOutputBoundary = new SignupPresenter(viewManagerModel, signupViewModel, loginViewModel); @@ -116,6 +251,81 @@ public AppBuilder addChangePasswordUseCase() { return this; } + public AppBuilder addFilterSearchUseCase() { + final FilterSearchOutputBoundary filterSearchOutputBoundary = new FilterSearchPresenter(filterSearchViewModel); + final FilterSearchInputBoundary filterSearchInteractor = + new FilterSearchInteractor(filterSearchDataAccessObject, filterSearchOutputBoundary); + + FilterSearchController filterSearchController = new FilterSearchController(filterSearchInteractor); + filterSearchView.setFilterSearchController(filterSearchController); + + loggedInView.setFilterSearchNavigation(viewManagerModel, filterSearchView.getViewName()); + filterSearchView.setBackNavigation(viewManagerModel, loggedInView.getViewName()); + return this; + } + public AppBuilder addStockSearchUseCase() { + stockSearchViewModel = new StockSearchViewModel(); + StockSearchOutputBoundary outputBoundary = new StockSearchPresenter(stockSearchViewModel); + StockSearchInputBoundary interactor = + new StockSearchInteractor(stockSearchDataAccessObject, outputBoundary); + + stockSearchController = new StockSearchController(interactor, stockSearchViewModel); + loggedInView.setStockSearchController(stockSearchController); + loggedInView.setStockSearchViewModel(stockSearchViewModel); + return this; + } + + public AppBuilder addNewsUsecase() { + // Logged-in page: News button → News view + loggedInView.setNewsNavigation(viewManagerModel, newsView.getViewName()); + + // News page: Back button → Logged-in view + newsView.setBackNavigation(viewManagerModel, loggedInView.getViewName()); + + return this; + } + public AppBuilder addEarningsHistoryUseCase() { + // Logged-in page: History button → Earnings history view + loggedInView.setHistoryNavigation( + viewManagerModel, earningsHistoryView.getViewName()); + + // Earnings history page: Back button → Logged-in view + earningsHistoryView.setBackNavigation( + viewManagerModel, loggedInView.getViewName()); + + return this; + } + + public AppBuilder addAccount() { + accountViewModel = new AccountViewModel(); + WatchlistOutputBoundary watchlistPresenter = + new AccountPresenter(accountViewModel); + WatchlistInputBoundary watchlistInteractor = + new WatchlistInteractor(watchlistDataAccessObject, watchlistPresenter); + AccountController accountController = new AccountController(watchlistInteractor); + + accountView = new AccountView(accountViewModel); + accountView.setController(accountController); + cardPanel.add(accountView, accountView.getViewName()); + loggedInView.setAccountController(accountController); + + // Logged in page: Account button → Account view + loggedInView.setAccountNavigation( + viewManagerModel, accountView.getViewName()); + viewManagerModel.addPropertyChangeListener(evt -> { + if (viewManagerModel.getState().equals(accountView.getViewName())) { + String username = userDataAccessObject.getCurrentUsername(); + accountView.loadAccount(username); + } + }); + + // Account page: Back button → Logged-in view + accountView.setBackNavigation( + viewManagerModel, loggedInView.getViewName()); + + return this; + } + /** * Adds the Logout Use Case to the application. * @return this builder @@ -132,8 +342,29 @@ public AppBuilder addLogoutUseCase() { return this; } + public AppBuilder addMarketStatusUseCase() { + marketStatusViewModel = new MarketStatusViewModel(); + MarketStatusOutputBoundary msPresenter = new MarketStatusPresenter(marketStatusViewModel); + MarketStatusDataAccessInterface msDao = marketStatusDataAccessObject; + MarketStatusInputBoundary msInteractor = new MarketStatusInteractor(msDao, msPresenter); + marketStatusController = new MarketStatusController(msInteractor); + loggedInView.setMarketStatusViewModel(marketStatusViewModel); + if (tradeView != null) { + tradeView.setMarketStatusViewModel(marketStatusViewModel); + } + marketStatusController.updateStatus(); + return this; + } + + public AppBuilder addRealtimeTradeUseCase() { + String tradeViewName = tradeView.getViewName(); + loggedInView.setRealtimeTradeNavigation(viewManagerModel, tradeViewName); + tradeView.setBackNavigation(viewManagerModel, loggedInView.getViewName()); + return this; + } + public JFrame build() { - final JFrame application = new JFrame("User Login Example"); + final JFrame application = new JFrame("Stock Application"); application.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); application.add(cardPanel); @@ -145,4 +376,4 @@ public JFrame build() { } -} +} \ No newline at end of file diff --git a/src/main/java/app/Main.java b/src/main/java/app/Main.java index 424404fb..34edebf0 100644 --- a/src/main/java/app/Main.java +++ b/src/main/java/app/Main.java @@ -9,13 +9,27 @@ public static void main(String[] args) { .addLoginView() .addSignupView() .addLoggedInView() + .addFilterSearchView() .addSignupUseCase() .addLoginUseCase() .addChangePasswordUseCase() + .addFilterSearchUseCase() + .addNewsView() + .addEarningsHistoryView() + .addTradeView() + .addStockSearchUseCase() + .addSignupUseCase() + .addLoginUseCase() + .addChangePasswordUseCase() + .addNewsUsecase() + .addEarningsHistoryUseCase() + .addAccount() + .addMarketStatusUseCase() + .addRealtimeTradeUseCase() .build(); application.pack(); application.setLocationRelativeTo(null); application.setVisible(true); } -} +} \ No newline at end of file diff --git a/src/main/java/data_access/FileUserDataAccessObject.java b/src/main/java/data_access/FileUserDataAccessObject.java index 4cae88c5..c90e0b80 100644 --- a/src/main/java/data_access/FileUserDataAccessObject.java +++ b/src/main/java/data_access/FileUserDataAccessObject.java @@ -6,6 +6,7 @@ import use_case.login.LoginUserDataAccessInterface; import use_case.logout.LogoutUserDataAccessInterface; import use_case.signup.SignupUserDataAccessInterface; +import use_case.watchlist.WatchlistUserDataAccessInterface; import java.io.*; import java.util.HashMap; @@ -18,7 +19,7 @@ public class FileUserDataAccessObject implements SignupUserDataAccessInterface, LoginUserDataAccessInterface, ChangePasswordUserDataAccessInterface, - LogoutUserDataAccessInterface { + LogoutUserDataAccessInterface, WatchlistUserDataAccessInterface { private static final String HEADER = "username,password"; diff --git a/src/main/java/data_access/FilterSearchDataAccessObject.java b/src/main/java/data_access/FilterSearchDataAccessObject.java new file mode 100644 index 00000000..c149552f --- /dev/null +++ b/src/main/java/data_access/FilterSearchDataAccessObject.java @@ -0,0 +1,146 @@ +package data_access; + +import entity.Stock; +import use_case.filter_search.FilterSearchDataAccessInterface; +import org.json.JSONObject; +import org.json.JSONArray; +import org.json.JSONTokener; + + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import java.io.IOException; +import java.util.List; + +public class FilterSearchDataAccessObject implements FilterSearchDataAccessInterface { + private static final String BASE_URL = "https://finnhub.io/api/v1"; + + private final String apiKey; + + public FilterSearchDataAccessObject(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public List loadStocks(String exchange, String mic, String securityType, String currency) throws Exception { + if (exchange == null || exchange.isBlank()) { + throw new IllegalArgumentException("Exchange must not be empty."); + } + StringBuilder urlBuilder = new StringBuilder(BASE_URL) + .append("/stock") + .append("/symbol?exchange=") + .append(URLEncoder.encode(exchange, StandardCharsets.UTF_8)); + + if (mic != null && !mic.isBlank()) { + urlBuilder.append("&mic=") + .append(URLEncoder.encode(mic, StandardCharsets.UTF_8)); + } + + if (securityType != null && !securityType.isBlank()) { + urlBuilder.append("&securityType=") + .append(URLEncoder.encode(securityType, StandardCharsets.UTF_8)); + } + + if (currency != null && !currency.isBlank()) { + urlBuilder.append("¤cy=") + .append(URLEncoder.encode(currency, StandardCharsets.UTF_8)); + } + + urlBuilder.append("&token=").append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); + + JSONArray array = fetchJsonArray(urlBuilder.toString()); + return parseStockArray(array); + } + + private JSONArray fetchJsonArray(String urlString) throws IOException { + HttpURLConnection connection = null; + InputStream inputStream = null; + + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + // Possible timeout + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + // Read errors + String errorBody = readStream(connection.getErrorStream()); + throw new IOException("HTTP error " + status + " from Finnhub: " + errorBody); + } + + inputStream = connection.getInputStream(); + JSONTokener tokener = new JSONTokener(inputStream); + return new JSONArray(tokener); + + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ignored) {} + } + if (connection != null) { + connection.disconnect(); + } + } + } + + private String readStream(InputStream stream) throws IOException { + if (stream == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + } + + private List parseStockArray(JSONArray array) { + List result = new ArrayList<>(); + if (array == null) { + return result; + } + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.optJSONObject(i); + if (obj == null) continue; + + String currency = obj.optString("currency", "US"); + String description = obj.optString("description", ""); + String displaySymbol = obj.optString("displaySymbol", ""); + String figi = obj.optString("figi", ""); + String symbol = obj.optString("symbol", ""); + String mic = obj.optString("mic", ""); + String type = obj.optString("type", ""); + + Stock stock = new Stock( + currency, + description, + displaySymbol, + figi, + symbol, + mic, + type + ); + result.add(stock); + } + return result; + } +} + diff --git a/src/main/java/data_access/FinnhubEarningsDataAccessObject.java b/src/main/java/data_access/FinnhubEarningsDataAccessObject.java new file mode 100644 index 00000000..1524405d --- /dev/null +++ b/src/main/java/data_access/FinnhubEarningsDataAccessObject.java @@ -0,0 +1,149 @@ +package data_access; + +import entity.EarningsRecord; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import use_case.earnings_history.EarningsDataAccessInterface; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Data access object that calls Finnhub's earnings endpoint and returns + * a list of EarningsRecord entities. + * + * Contract with EarningsDataAccessInterface: + * - return null -> symbol not found / unsupported (API returns "error") + * - return empty -> symbol valid but no earnings data + * - throw IOException -> network / HTTP issue + */ +public class FinnhubEarningsDataAccessObject implements EarningsDataAccessInterface { + + private static final String API_KEY = "d4977ehr01qshn3kvpt0d4977ehr01qshn3kvptg"; + private static final String BASE_URL = "https://finnhub.io/api/v1/stock/earnings"; + + private final OkHttpClient client = new OkHttpClient.Builder() + .callTimeout(10, TimeUnit.SECONDS) + .build(); + + @Override + public List getEarningsFor(String symbol) throws IOException { + if (symbol == null || symbol.trim().isEmpty()) { + return new ArrayList<>(); + } + + symbol = symbol.trim().toUpperCase(); + + HttpUrl url = HttpUrl.parse(BASE_URL).newBuilder() + .addQueryParameter("symbol", symbol) + .addQueryParameter("limit", "10") + .addQueryParameter("token", API_KEY) + .build(); + + Request request = new Request.Builder() + .url(url) + .build(); + + try (Response response = client.newCall(request).execute()) { + + if (!response.isSuccessful()) { + throw new IOException("Unexpected HTTP code " + response); + } + + String body = response.body() != null ? response.body().string() : ""; + + // Finnhub returns { "error": "..." } for bad symbols + if (body.contains("\"error\"")) { + return null; + } + + return parseEarningsJson(body); + } + } + + // ---------------- JSON parsing helpers ---------------- + + /** + * Expected JSON: + * [ { "period": "...", "actual": ..., "estimate": ..., "surprise": ... }, ... ] + */ + private List parseEarningsJson(String json) { + List rows = new ArrayList<>(); + if (json == null) return rows; + + String trimmed = json.trim(); + if (trimmed.length() < 2 || "[]".equals(trimmed)) return rows; + + // Remove [ and ] + if (trimmed.startsWith("[")) { + trimmed = trimmed.substring(1); + } + if (trimmed.endsWith("]")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + + // Split by "},{" between objects + String[] objects = trimmed.split("\\},\\s*\\{"); + + for (String obj : objects) { + obj = obj.trim(); + if (!obj.startsWith("{")) obj = "{" + obj; + if (!obj.endsWith("}")) obj = obj + "}"; + + String period = extractValue(obj, "period"); + String actualStr = extractValue(obj, "actual"); + String estimateStr = extractValue(obj, "estimate"); + String surpriseStr = extractValue(obj, "surprise"); + + Double actual = parseDoubleOrNull(actualStr); + Double estimate = parseDoubleOrNull(estimateStr); + Double surprise = parseDoubleOrNull(surpriseStr); + + rows.add(new EarningsRecord(period, actual, estimate, surprise)); + } + return rows; + } + + private Double parseDoubleOrNull(String s) { + if (s == null || s.isEmpty() || "null".equals(s)) return null; + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; + } + } + + private String extractValue(String json, String key) { + String keySearch = "\"" + key + "\":"; + int keyIndex = json.indexOf(keySearch); + if (keyIndex == -1) return null; + + int valueStart = keyIndex + keySearch.length(); + if (valueStart >= json.length()) return null; + + char firstChar = json.charAt(valueStart); + + // String value in quotes + if (firstChar == '"') { + valueStart++; + int valueEnd = json.indexOf('"', valueStart); + if (valueEnd != -1) { + return json.substring(valueStart, valueEnd); + } + } else { // number / null / boolean + int indexComma = json.indexOf(',', valueStart); + int indexCurly = json.indexOf('}', valueStart); + + int terminationIndex = json.length(); + if (indexComma != -1) terminationIndex = Math.min(terminationIndex, indexComma); + if (indexCurly != -1) terminationIndex = Math.min(terminationIndex, indexCurly); + + return json.substring(valueStart, terminationIndex).trim(); + } + return null; + } +} diff --git a/src/main/java/data_access/FinnhubTradeDataAccessObject.java b/src/main/java/data_access/FinnhubTradeDataAccessObject.java new file mode 100644 index 00000000..e7b7e080 --- /dev/null +++ b/src/main/java/data_access/FinnhubTradeDataAccessObject.java @@ -0,0 +1,330 @@ +package data_access; + +import entity.Trade; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import use_case.trade.TradeDataAccessInterface; +import use_case.trade.TradeListener; + +import java.time.Instant; +import java.util.concurrent.TimeUnit; + +/** + * Finnhub-specific implementation of the TradeDataAccessInterface. + * This class belongs to the data_access / infrastructure layer and knows about WebSockets and JSON format. + */ +public class FinnhubTradeDataAccessObject implements TradeDataAccessInterface { + + private static final String API_KEY = "d4977ehr01qshn3kvpt0d4977ehr01qshn3kvptg"; + private static final String WEB_SOCKET_URL = "wss://ws.finnhub.io?token=" + + API_KEY; + private static final int UNSUBSCRIBE_DELAY_MS = 100; + private static final int RECONNECT_DELAY_MS = 200; + private static final int CLOSE_CODE_NORMAL = 1000; + private static final int CONNECT_TIMEOUT_SECONDS = 10; + private static final int PING_INTERVAL_SECONDS = 25; + private static final int DATA_ARRAY_OFFSET = 8; + + private WebSocket webSocket; + private OkHttpClient client; + private TradeListener listener; + private String currentSymbol; + private boolean hasReceivedPing = false; + + @Override + public void connect(String symbol, TradeListener listener) { + this.listener = listener; + + if (symbol != null) { + symbol = symbol.trim().toUpperCase(); + } + + if (webSocket != null) { + if (currentSymbol != null && !currentSymbol.isEmpty()) { + try { + String unsubscribeMsg = String.format( + "{\"type\":\"unsubscribe\",\"symbol\":\"%s\"}", + currentSymbol); + webSocket.send(unsubscribeMsg); + try { + Thread.sleep(UNSUBSCRIBE_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } catch (Exception e) { + // Error unsubscribing + } + } + webSocket.close(CLOSE_CODE_NORMAL, "Reconnecting"); + webSocket = null; + try { + Thread.sleep(RECONNECT_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + if (symbol != null) { + this.currentSymbol = symbol.trim(); + } else { + this.currentSymbol = null; + } + this.hasReceivedPing = false; + + client = new OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .pingInterval(PING_INTERVAL_SECONDS, TimeUnit.SECONDS) + .build(); + + Request request = new Request.Builder().url(WEB_SOCKET_URL).build(); + + if (listener != null) { + listener.onStatusChanged("Status: Connecting...", false); + } + + webSocket = client.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(WebSocket ws, Response response) { + String subscribeMsg = String.format( + "{\"type\":\"subscribe\",\"symbol\":\"%s\"}", + currentSymbol); + ws.send(subscribeMsg); + } + + @Override + public void onMessage(WebSocket ws, String text) { + processMessage(text); + } + }); + } + + @Override + public void disconnect() { + if (webSocket != null) { + if (currentSymbol != null && !currentSymbol.isEmpty()) { + try { + String unsubscribeMsg = String.format( + "{\"type\":\"unsubscribe\",\"symbol\":\"%s\"}", + currentSymbol); + webSocket.send(unsubscribeMsg); + try { + Thread.sleep(UNSUBSCRIBE_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } catch (Exception e) { + // Error unsubscribing + } + } + webSocket.close(CLOSE_CODE_NORMAL, "User disconnect"); + webSocket = null; + } + client = null; + currentSymbol = null; + } + + /** + * Small internal JSON parsing helper: extracts a value from a JSON string using basic String methods. + * Kept here in the infrastructure layer so UI and domain remain free of parsing details. + */ + private String extractValue(String json, String key) { + String keySearch = "\"" + key + "\":"; + int keyIndex = json.indexOf(keySearch); + + if (keyIndex == -1) { + return null; + } + + int valueStart = keyIndex + keySearch.length(); + char firstChar = json.charAt(valueStart); + + if (firstChar == '"') { + valueStart++; + int valueEnd = json.indexOf('"', valueStart); + if (valueEnd != -1) { + return json.substring(valueStart, valueEnd); + } + } else { + int valueEnd = valueStart; + while (valueEnd < json.length() + && (Character.isDigit(json.charAt(valueEnd)) + || json.charAt(valueEnd) == '.' + || json.charAt(valueEnd) == '-')) { + valueEnd++; + } + int commaIndex = json.indexOf(',', valueStart); + int braceIndex = json.indexOf('}', valueStart); + + int terminationIndex = json.length(); + if (commaIndex != -1) { + terminationIndex = Math.min(terminationIndex, commaIndex); + } + if (braceIndex != -1) { + terminationIndex = Math.min(terminationIndex, braceIndex); + } + + if (terminationIndex > valueStart) { + return json.substring(valueStart, terminationIndex).trim(); + } + } + return null; + } + + /** + * Processes trade data that comes in array format from Finnhub. + * Format: {"type":"trade","data":[{"s":"SYMBOL","p":price,"v":volume,"t":timestamp},...]} + * Or: [{"s":"SYMBOL","p":price,"v":volume,"t":timestamp},...] + */ + private void processTradeArray(String jsonMessage) { + try { + String dataArray = null; + if (jsonMessage.contains("\"data\":[")) { + int dataStart = jsonMessage.indexOf("\"data\":[") + + DATA_ARRAY_OFFSET; + int bracketCount = 1; + int dataEnd = dataStart; + while (dataEnd < jsonMessage.length() && bracketCount > 0) { + if (jsonMessage.charAt(dataEnd) == '[') { + bracketCount++; + } + if (jsonMessage.charAt(dataEnd) == ']') { + bracketCount--; + } + dataEnd++; + } + if (bracketCount == 0) { + dataArray = jsonMessage.substring(dataStart, dataEnd - 1); + } + } else if (jsonMessage.trim().startsWith("[")) { + String trimmedMessage = jsonMessage.trim(); + dataArray = trimmedMessage.substring(1, + trimmedMessage.length() - 1); + } + + if (dataArray == null || dataArray.isEmpty()) { + return; + } + String[] tradeObjects = dataArray.split("\\},\\{"); + for (int i = 0; i < tradeObjects.length; i++) { + String tradeObj = tradeObjects[i]; + tradeObj = tradeObj.replaceFirst("^\\{?", "{").replaceFirst("\\}?$", "}"); + if (!tradeObj.startsWith("{")) { + tradeObj = "{" + tradeObj; + } + if (!tradeObj.endsWith("}")) { + tradeObj = tradeObj + "}"; + } + + String symbol = extractValue(tradeObj, "s"); + String normalizedReceivedSymbol = (symbol != null) ? symbol.trim() : null; + String normalizedCurrentSymbol = (currentSymbol != null) ? currentSymbol.trim() : null; + + if (normalizedCurrentSymbol != null + && normalizedReceivedSymbol != null + && normalizedCurrentSymbol + .equalsIgnoreCase(normalizedReceivedSymbol)) { + + String priceStr = extractValue(tradeObj, "p"); + double price = (priceStr != null) ? Double.parseDouble(priceStr) : 0.0; + + String volumeStr = extractValue(tradeObj, "v"); + double volume = (volumeStr != null) ? Double.parseDouble(volumeStr) : 0.0; + + String timestampStr = extractValue(tradeObj, "t"); + long timestamp = (timestampStr != null) ? Long.parseLong(timestampStr) : 0L; + + Instant ts = timestamp > 0 ? Instant.ofEpochMilli(timestamp) : null; + + if (listener != null) { + listener.onStatusChanged("Status: Connected to " + + currentSymbol, false); + Trade trade = new Trade(symbol, price, volume, ts); + listener.onTrade(trade); + } + } + } + } catch (Exception e) { + // Error processing trade array + } + } + + /** + * Parses the incoming JSON message using manual string manipulation and dispatches Trade domain objects. + * Based on working Finnhub WebSocket example. + */ + private void processMessage(String jsonMessage) { + try { + if (jsonMessage.contains("\"type\":\"ping\"")) { + hasReceivedPing = true; + return; + } + + if (jsonMessage.contains("\"type\":\"trade\"")) { + if (jsonMessage.contains("\"data\":[")) { + processTradeArray(jsonMessage); + return; + } + + String symbol = extractValue(jsonMessage, "s"); + + String priceStr = extractValue(jsonMessage, "p"); + double price; + if (priceStr != null) { + price = Double.parseDouble(priceStr); + } else { + price = 0.0; + } + + String volumeStr = extractValue(jsonMessage, "v"); + double volume; + if (volumeStr != null) { + volume = Double.parseDouble(volumeStr); + } else { + volume = 0.0; + } + + String timestampStr = extractValue(jsonMessage, "t"); + long timestamp; + if (timestampStr != null) { + timestamp = Long.parseLong(timestampStr); + } else { + timestamp = 0L; + } + + Instant ts; + if (timestamp > 0) { + ts = Instant.ofEpochMilli(timestamp); + } else { + ts = null; + } + + + if (listener != null) { + listener.onStatusChanged("Status: Connected to " + + currentSymbol, false); + Trade trade = new Trade(symbol, price, volume, ts); + listener.onTrade(trade); + } + } else if (jsonMessage.contains("\"type\":\"error\"")) { + String errorMsg = extractValue(jsonMessage, "msg"); + if (listener != null) { + String errorText = errorMsg != null ? errorMsg : "Unknown error"; + listener.onStatusChanged("Status: Error - " + errorText, true); + } + } else { + if (jsonMessage.trim().startsWith("[") + && jsonMessage.contains("\"s\"") + && jsonMessage.contains("\"p\"")) { + processTradeArray(jsonMessage); + } + } + } catch (Exception e) { + // Error processing JSON message + } + } +} + diff --git a/src/main/java/data_access/MarketStatusDataAccessObject.java b/src/main/java/data_access/MarketStatusDataAccessObject.java new file mode 100644 index 00000000..2c3863af --- /dev/null +++ b/src/main/java/data_access/MarketStatusDataAccessObject.java @@ -0,0 +1,108 @@ +package data_access; + +import entity.MarketStatus; +import org.json.JSONObject; +import org.json.JSONTokener; +import use_case.market_status.MarketStatusDataAccessInterface; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public class MarketStatusDataAccessObject implements MarketStatusDataAccessInterface { + private static final String BASE_URL = "https://finnhub.io/api/v1/stock/market-status"; + + private final String apiKey; + + public MarketStatusDataAccessObject(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public MarketStatus loadStatus() throws Exception { + String urlString = BASE_URL + + "?exchange=US" + + "&token=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + + JSONObject json = fetchJsonObject(urlString); + return parseMarketStatus(json); + } + + // Perform HTTP GET and return the response as a JSONObject. + private JSONObject fetchJsonObject(String urlString) throws IOException { + HttpURLConnection connection = null; + InputStream inputStream = null; + + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + String errorBody = readStream(connection.getErrorStream()); + throw new IOException("HTTP error " + status + " from Finnhub: " + errorBody); + } + + inputStream = connection.getInputStream(); + JSONTokener tokener = new JSONTokener(inputStream); + return new JSONObject(tokener); + + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ignored) {} + } + if (connection != null) { + connection.disconnect(); + } + } + } + + // Convert errorMessage + private String readStream(InputStream stream) throws IOException { + if (stream == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + } + + + private MarketStatus parseMarketStatus(JSONObject obj) { + if (obj == null) { + return null; + } + + String exchange = obj.optString("exchange", "US"); + boolean isOpen = obj.optBoolean("isOpen", false); + String session = obj.optString("session", ""); + String holiday = obj.optString("holiday", ""); + long timestamp = obj.optLong("t", 0L); + String timezone = obj.optString("timezone", ""); + + return new MarketStatus( + exchange, + isOpen, + session, + holiday, + timestamp, + timezone + ); + } +} diff --git a/src/main/java/data_access/NewsDataAccessObject.java b/src/main/java/data_access/NewsDataAccessObject.java new file mode 100644 index 00000000..7c79a53d --- /dev/null +++ b/src/main/java/data_access/NewsDataAccessObject.java @@ -0,0 +1,179 @@ +package data_access; + +import entity.NewsArticle; +import use_case.news.NewsDataAccessInterface; + +import org.json.JSONObject; +import org.json.JSONArray; +import org.json.JSONTokener; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Data access implementation for Finnhub news endpoints using org.json. + *

+ * Endpoints: + * - General market news: + * - Company news: + */ +public class NewsDataAccessObject implements NewsDataAccessInterface { + + private static final String BASE_URL = "https://finnhub.io/api/v1"; + + private final String apiKey; + + public NewsDataAccessObject(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public List loadMarketNews() throws Exception { + String endpoint = BASE_URL + "/news?category=general&token=" + + URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + JSONArray array = fetchJsonArray(endpoint); + return parseNewsArray(array); + } + + @Override + public List loadCompanyNews(String symbol, + String fromDate, + String toDate) throws Exception { + if (symbol == null || symbol.isBlank()) { + throw new IllegalArgumentException("Symbol must not be empty for company news."); + } + + // It is okay if fromDate or toDate are null/blank: Finnhub may require them, + // so we can set defaults here (for example: last 7 days) + StringBuilder urlBuilder = new StringBuilder(BASE_URL) + .append("/company-news") + .append("?symbol=").append(URLEncoder.encode(symbol, StandardCharsets.UTF_8)); + + if (fromDate != null && !fromDate.isBlank()) { + urlBuilder.append("&from=").append(URLEncoder.encode(fromDate, StandardCharsets.UTF_8)); + } + if (toDate != null && !toDate.isBlank()) { + urlBuilder.append("&to=").append(URLEncoder.encode(toDate, StandardCharsets.UTF_8)); + } + + urlBuilder.append("&token=").append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); + + JSONArray array = fetchJsonArray(urlBuilder.toString()); + return parseNewsArray(array); + } + + /** + * Helper: performs HTTP GET and returns the response as a JSONArray. + */ + private JSONArray fetchJsonArray(String urlString) throws IOException { + HttpURLConnection connection = null; + InputStream inputStream = null; + + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + // Possible timeout + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + // Read errors + String errorBody = readStream(connection.getErrorStream()); + throw new IOException("HTTP error " + status + " from Finnhub: " + errorBody); + } + + inputStream = connection.getInputStream(); + JSONTokener tokener = new JSONTokener(inputStream); + return new JSONArray(tokener); + + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ignored) {} + } + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * Helper: read an InputStream fully into a String (for error messages). + */ + private String readStream(InputStream stream) throws IOException { + if (stream == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + } + + /** + * Parse JSON array into a list of NewsArticle entities. + *

+ * Each object typically has fields: + * - id (long) + * - category (String) + * - datetime (long) + * - headline (String) + * - image (String) + * - related (String) // comma-separated symbols + * - source (String) + * - summary (String) + * - url (String) + */ + private List parseNewsArray(JSONArray array) { + List result = new ArrayList<>(); + if (array == null) { + return result; + } + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.optJSONObject(i); + if (obj == null) continue; + + long id = obj.optLong("id", 0L); + String category = obj.optString("category", ""); + long datetime = obj.optLong("datetime", 0L); + String headline = obj.optString("headline", ""); + String image = obj.optString("image", ""); + String related = obj.optString("related", ""); + String source = obj.optString("source", ""); + String summary = obj.optString("summary", ""); + String url = obj.optString("url", ""); + + NewsArticle article = new NewsArticle( + id, + category, + datetime, + headline, + image, + related, + source, + summary, + url + ); + result.add(article); + } + + return result; + } +} diff --git a/src/main/java/data_access/stock_search/FinnhubStockSearchDataAccessObject.java b/src/main/java/data_access/stock_search/FinnhubStockSearchDataAccessObject.java new file mode 100644 index 00000000..940d02a1 --- /dev/null +++ b/src/main/java/data_access/stock_search/FinnhubStockSearchDataAccessObject.java @@ -0,0 +1,191 @@ +package data_access.stock_search; + +import entity.StockQuote; +import org.json.JSONArray; +import org.json.JSONObject; +import use_case.stock_search.StockSearchDataAccessInterface; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +public class FinnhubStockSearchDataAccessObject implements StockSearchDataAccessInterface { + private static final String QUOTE_URL = "https://finnhub.io/api/v1/quote"; + private static final String SEARCH_URL = "https://finnhub.io/api/v1/search"; + private static final String PROFILE_URL = "https://finnhub.io/api/v1/stock/profile2"; + + private final String apiKey; + + public FinnhubStockSearchDataAccessObject(String apiKey) { + this.apiKey = apiKey; + } + + @Override + public StockQuote loadQuote(String userQuery) throws Exception { + if (userQuery == null || userQuery.isBlank()) { + throw new IllegalArgumentException("Symbol or name must not be empty."); + } + + String query = userQuery.trim(); + + String resolvedSymbol = resolveSymbol(query); + if (resolvedSymbol == null || resolvedSymbol.isBlank()) { + throw new IllegalStateException("No matching symbol found for: " + query); + } + + StringBuilder urlBuilder = new StringBuilder(QUOTE_URL) + .append("?symbol=") + .append(URLEncoder.encode(resolvedSymbol, StandardCharsets.UTF_8)) + .append("&token=") + .append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); + + JSONObject quoteJson = fetchJsonObject(urlBuilder.toString()); + + // Load basic profile information for extra context + StringBuilder profileUrlBuilder = new StringBuilder(PROFILE_URL) + .append("?symbol=") + .append(URLEncoder.encode(resolvedSymbol, StandardCharsets.UTF_8)) + .append("&token=") + .append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); + + JSONObject profileJson = fetchJsonObject(profileUrlBuilder.toString()); + + return parseQuote(resolvedSymbol, quoteJson, profileJson); + } + + /** + * Resolve an arbitrary query (symbol, name, ISIN, CUSIP) to a concrete ticker symbol + * using Finnhub's /search endpoint. + */ + private String resolveSymbol(String query) throws IOException { + StringBuilder urlBuilder = new StringBuilder(SEARCH_URL) + .append("?q=") + .append(URLEncoder.encode(query, StandardCharsets.UTF_8)) + .append("&token=") + .append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); + + JSONObject json = fetchJsonObject(urlBuilder.toString()); + if (json == null) { + return null; + } + + JSONArray results = json.optJSONArray("result"); + if (results == null || results.length() == 0) { + return null; + } + + // 1) Exact symbol match (case-insensitive) + for (int i = 0; i < results.length(); i++) { + JSONObject r = results.optJSONObject(i); + if (r == null) continue; + String sym = r.optString("symbol", ""); + if (!sym.isEmpty() && sym.equalsIgnoreCase(query)) { + return sym; + } + } + + String lowerQuery = query.toLowerCase(); + + // 2) Description contains query (case-insensitive) + for (int i = 0; i < results.length(); i++) { + JSONObject r = results.optJSONObject(i); + if (r == null) continue; + String desc = r.optString("description", ""); + if (!desc.isEmpty() && desc.toLowerCase().contains(lowerQuery)) { + String sym = r.optString("symbol", ""); + if (!sym.isEmpty()) { + return sym; + } + } + } + + // 3) Fallback to the first result's symbol + JSONObject first = results.optJSONObject(0); + if (first == null) { + return null; + } + String sym = first.optString("symbol", ""); + return sym.isEmpty() ? null : sym; + } + + private JSONObject fetchJsonObject(String urlString) throws IOException { + HttpURLConnection connection = null; + InputStream inputStream = null; + + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + String errorBody = readStream(connection.getErrorStream()); + throw new IOException("HTTP error " + status + " from Finnhub: " + errorBody); + } + + inputStream = connection.getInputStream(); + String body = readStream(inputStream); + return new JSONObject(body); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ignored) {} + } + if (connection != null) { + connection.disconnect(); + } + } + } + + private String readStream(InputStream stream) throws IOException { + if (stream == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + } + + private StockQuote parseQuote(String symbol, JSONObject quoteJson, JSONObject profileJson) { + if (quoteJson == null) { + throw new IllegalStateException("Empty response from Finnhub quote endpoint."); + } + + double current = quoteJson.optDouble("c", Double.NaN); + double open = quoteJson.optDouble("o", Double.NaN); + double high = quoteJson.optDouble("h", Double.NaN); + double low = quoteJson.optDouble("l", Double.NaN); + double prevClose = quoteJson.optDouble("pc", Double.NaN); + long t = quoteJson.optLong("t", 0L); + + String companyName = null; + String exchange = null; + String industry = null; + double marketCap = Double.NaN; + + if (profileJson != null) { + // Fields from /stock/profile2 + companyName = profileJson.optString("name", null); + exchange = profileJson.optString("exchange", null); + // Finnhub industry field can be "finnhubIndustry" or sector-like labels + industry = profileJson.optString("finnhubIndustry", null); + marketCap = profileJson.optDouble("marketCapitalization", Double.NaN); + } + + return new StockQuote(symbol, companyName, exchange, industry, marketCap, + current, open, high, low, prevClose, t); + } +} diff --git a/src/main/java/entity/EarningsRecord.java b/src/main/java/entity/EarningsRecord.java new file mode 100644 index 00000000..2cf3864f --- /dev/null +++ b/src/main/java/entity/EarningsRecord.java @@ -0,0 +1,34 @@ +package entity; + +/** + * Entity representing one earnings record returned by the API. + */ +public class EarningsRecord { + private final String period; + private final Double actual; + private final Double estimate; + private final Double surprise; + + public EarningsRecord(String period, Double actual, Double estimate, Double surprise) { + this.period = period; + this.actual = actual; + this.estimate = estimate; + this.surprise = surprise; + } + + public String getPeriod() { + return period; + } + + public Double getActual() { + return actual; + } + + public Double getEstimate() { + return estimate; + } + + public Double getSurprise() { + return surprise; + } +} diff --git a/src/main/java/entity/MarketStatus.java b/src/main/java/entity/MarketStatus.java new file mode 100644 index 00000000..9f24c44a --- /dev/null +++ b/src/main/java/entity/MarketStatus.java @@ -0,0 +1,36 @@ +package entity; + +public class MarketStatus { + private final String exchange; // e.g. "US" + private final boolean open; // true if market is open + private final String session; // "pre", "regular", "post" + private final String holiday; // holiday name if closed, else "" + private final long timestamp; // UNIX seconds + private final String timezone; // e.g. "America/New_York" + + public MarketStatus(String exchange, + boolean open, + String session, + String holiday, + long timestamp, + String timezone) { + this.exchange = exchange; + this.open = open; + this.session = session; + this.holiday = holiday; + this.timestamp = timestamp; + this.timezone = timezone; + } + + public String getExchange() {return exchange;} + + public boolean isOpen() {return open;} + + public String getSession() {return session;} + + public String getHoliday() {return holiday;} + + public long getTimestamp() {return timestamp;} + + public String getTimezone() {return timezone;} +} diff --git a/src/main/java/entity/NewsArticle.java b/src/main/java/entity/NewsArticle.java new file mode 100644 index 00000000..c8214f79 --- /dev/null +++ b/src/main/java/entity/NewsArticle.java @@ -0,0 +1,53 @@ +package entity; + +public class NewsArticle { + + // Fields from Finnhub API + private final long id; // unique article id + private final String category; // e.g. "general", "crypto", etc. + private final long datetime; // UNIX timestamp (seconds) + private final String headline; // article title + private final String image; // image URL + private final String related; // related stock and companies, e.g. "AAPL,Apple.Inc" + private final String source; // e.g., "Bloomberg" + private final String summary; // short description + private final String url; // link to full article + + public NewsArticle(long id, + String category, + long datetime, + String headline, + String image, + String related, + String source, + String summary, + String url) { + this.id = id; + this.category = category; + this.datetime = datetime; + this.headline = headline; + this.image = image; + this.related = related; + this.source = source; + this.summary = summary; + this.url = url; + } + + public long getId() {return id;} + + public String getCategory() {return category;} + + public long getDatetime() {return datetime;} + + public String getHeadline() {return headline;} + + public String getImage() {return image;} + + public String getRelated() {return related;} + + public String getSource() {return source;} + + public String getSummary() {return summary;} + + public String getUrl() {return url;} +} \ No newline at end of file diff --git a/src/main/java/entity/Stock.java b/src/main/java/entity/Stock.java new file mode 100644 index 00000000..5b80a6fa --- /dev/null +++ b/src/main/java/entity/Stock.java @@ -0,0 +1,51 @@ +package entity; + +public class Stock { + private final String mic; + private final String type; + private final String currency; + private final String description; + private final String displaySymbol; + private final String figi; + private final String symbol; + + public Stock(String currency, String description, String displaySymbol, + String figi, String mic, String symbol, String type) { + this.currency = currency; + this.description = description; + this.displaySymbol = displaySymbol; + this.mic = mic; + this.type = type; + this.symbol = symbol; + this.figi = figi; + + } + + public String getMic() { + return mic; + } + + public String getType() { + return type; + } + + public String getCurrency() { + return currency; + } + + public String getDescription() { + return description; + } + + public String getDisplaySymbol() { + return displaySymbol; + } + + public String getFigi() { + return figi; + } + + public String getSymbol() { + return symbol; + } +} diff --git a/src/main/java/entity/StockQuote.java b/src/main/java/entity/StockQuote.java new file mode 100644 index 00000000..6a4f027a --- /dev/null +++ b/src/main/java/entity/StockQuote.java @@ -0,0 +1,83 @@ +package entity; + +public class StockQuote { + private final String symbol; + private final String companyName; + private final String exchange; + private final String industry; + private final double marketCap; + private final double currentPrice; + private final double open; + private final double high; + private final double low; + private final double previousClose; + private final long timestamp; + + public StockQuote(String symbol, + String companyName, + String exchange, + String industry, + double marketCap, + double currentPrice, + double open, + double high, + double low, + double previousClose, + long timestamp) { + this.symbol = symbol; + this.companyName = companyName; + this.exchange = exchange; + this.industry = industry; + this.marketCap = marketCap; + this.currentPrice = currentPrice; + this.open = open; + this.high = high; + this.low = low; + this.previousClose = previousClose; + this.timestamp = timestamp; + } + + public String getSymbol() { + return symbol; + } + + public String getCompanyName() { + return companyName; + } + + public String getExchange() { + return exchange; + } + + public String getIndustry() { + return industry; + } + + public double getMarketCap() { + return marketCap; + } + + public double getCurrentPrice() { + return currentPrice; + } + + public double getOpen() { + return open; + } + + public double getHigh() { + return high; + } + + public double getLow() { + return low; + } + + public double getPreviousClose() { + return previousClose; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/entity/Trade.java b/src/main/java/entity/Trade.java new file mode 100644 index 00000000..149d37f2 --- /dev/null +++ b/src/main/java/entity/Trade.java @@ -0,0 +1,34 @@ +package entity; + +import java.time.Instant; + +public class Trade { + private final String symbol; + private final double price; + private final double volume; + private final Instant timestamp; + + public Trade(String symbol, double price, double volume, Instant timestamp) { + this.symbol = symbol; + this.price = price; + this.volume = volume; + this.timestamp = timestamp; + } + + public String getSymbol() { + return symbol; + } + + public double getPrice() { + return price; + } + + public double getVolume() { + return volume; + } + + public Instant getTimestamp() { + return timestamp; + } +} + diff --git a/src/main/java/entity/User.java b/src/main/java/entity/User.java index bf9783d0..49257ddb 100644 --- a/src/main/java/entity/User.java +++ b/src/main/java/entity/User.java @@ -1,5 +1,9 @@ package entity; +import org.json.JSONObject; + +import java.util.ArrayList; + /** * A simple entity representing a user. Users have a username and password.. */ @@ -7,6 +11,7 @@ public class User { private final String name; private final String password; + private final ArrayList watchlist = new ArrayList<>(); /** * Creates a new user with the given non-empty name and non-empty password. @@ -33,4 +38,6 @@ public String getPassword() { return password; } + public ArrayList getWatchlist() { return watchlist; } + } diff --git a/src/main/java/interface_adapter/account/AccountController.java b/src/main/java/interface_adapter/account/AccountController.java new file mode 100644 index 00000000..5e5389ba --- /dev/null +++ b/src/main/java/interface_adapter/account/AccountController.java @@ -0,0 +1,24 @@ +package interface_adapter.account; + +import org.json.JSONObject; +import use_case.watchlist.WatchlistInputBoundary; +import use_case.watchlist.WatchlistInputData; + +public class AccountController { + + private final WatchlistInputBoundary interactor; + + public AccountController(WatchlistInputBoundary watchlistInputBoundary) { + this.interactor = watchlistInputBoundary; + } + + public void addToWatchlist(String username, JSONObject watchlistInputData) { + WatchlistInputData inputData = new WatchlistInputData(username, watchlistInputData); + interactor.execute(inputData); + } + + public void loadWatchlist(String username) { + WatchlistInputData inputData = new WatchlistInputData(username, null); + interactor.execute(inputData); + } +} diff --git a/src/main/java/interface_adapter/account/AccountPresenter.java b/src/main/java/interface_adapter/account/AccountPresenter.java new file mode 100644 index 00000000..708da4e1 --- /dev/null +++ b/src/main/java/interface_adapter/account/AccountPresenter.java @@ -0,0 +1,29 @@ +package interface_adapter.account; + +import use_case.watchlist.WatchlistOutputBoundary; +import use_case.watchlist.WatchlistOutputData; + +import java.util.ArrayList; + +public class AccountPresenter implements WatchlistOutputBoundary { + + private final AccountViewModel viewModel; + + public AccountPresenter(AccountViewModel viewModel) { + this.viewModel = viewModel; + } + + public void prepareSuccessView(WatchlistOutputData watchlistOutputData) { + AccountState state = viewModel.getState(); + state.setWatchlist(watchlistOutputData.getWatchlist()); + viewModel.setState(state); + viewModel.firePropertyChange(); + } + + public void prepareFailView(String errorMessage) { + viewModel.getState().setWatchlist(new ArrayList<>()); + viewModel.firePropertyChange(); + } + +} + diff --git a/src/main/java/interface_adapter/account/AccountState.java b/src/main/java/interface_adapter/account/AccountState.java new file mode 100644 index 00000000..61b7cb44 --- /dev/null +++ b/src/main/java/interface_adapter/account/AccountState.java @@ -0,0 +1,30 @@ +package interface_adapter.account; + +import org.json.JSONObject; + +import java.util.ArrayList; + +public class AccountState { + private String username; + private ArrayList watchlist = new ArrayList<>(); + + public ArrayList getWatchlist() { + if (watchlist == null) { + watchlist = new ArrayList<>(); + } + return watchlist; + } + + public void setWatchlist(ArrayList watchlist) { + this.watchlist = watchlist; + } + + public void addToWatchlist(JSONObject watchlistInputData) { + this.watchlist.add(watchlistInputData); + } + + public String getUsername() { return username; } + + public void setUsername(String username) { this.username = username; } + +} diff --git a/src/main/java/interface_adapter/account/AccountViewModel.java b/src/main/java/interface_adapter/account/AccountViewModel.java new file mode 100644 index 00000000..0ba98978 --- /dev/null +++ b/src/main/java/interface_adapter/account/AccountViewModel.java @@ -0,0 +1,32 @@ +package interface_adapter.account; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; + +public class AccountViewModel { + private AccountState state = new AccountState(); + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + + public AccountState getState() { + return state; + } + + public void setState(AccountState newState) { + this.state = newState; + support.firePropertyChange("state", null, newState); + } + + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + public void removePropertyChangeListener(PropertyChangeListener listener) { + support.removePropertyChangeListener(listener); + } + + public void firePropertyChange() { + support.firePropertyChange("watchlist", null, state.getWatchlist()); + } + + +} diff --git a/src/main/java/interface_adapter/earnings_history/EarningsHistoryController.java b/src/main/java/interface_adapter/earnings_history/EarningsHistoryController.java new file mode 100644 index 00000000..318894e4 --- /dev/null +++ b/src/main/java/interface_adapter/earnings_history/EarningsHistoryController.java @@ -0,0 +1,32 @@ +package interface_adapter.earnings_history; + +import use_case.earnings_history.GetEarningsHistoryInputBoundary; +import use_case.earnings_history.GetEarningsHistoryInputData; + +public class EarningsHistoryController { + + private final GetEarningsHistoryInputBoundary interactor; + private final EarningsHistoryViewModel viewModel; + + public EarningsHistoryController(GetEarningsHistoryInputBoundary interactor, + EarningsHistoryViewModel viewModel) { + this.interactor = interactor; + this.viewModel = viewModel; + } + + public void onLoadButtonClicked(String symbol) { + EarningsHistoryState state = new EarningsHistoryState(viewModel.getState()); + state.setSymbol(symbol); + state.setLoading(true); + state.setMessage("", EarningsHistoryState.MessageType.NONE); + viewModel.setState(state); + viewModel.firePropertyChanged(); + + // background thread + new Thread(() -> { + GetEarningsHistoryInputData inputData = + new GetEarningsHistoryInputData(symbol); + interactor.execute(inputData); + }).start(); + } +} diff --git a/src/main/java/interface_adapter/earnings_history/EarningsHistoryPresenter.java b/src/main/java/interface_adapter/earnings_history/EarningsHistoryPresenter.java new file mode 100644 index 00000000..bcfc1e78 --- /dev/null +++ b/src/main/java/interface_adapter/earnings_history/EarningsHistoryPresenter.java @@ -0,0 +1,61 @@ +package interface_adapter.earnings_history; + +import entity.EarningsRecord; +import use_case.earnings_history.GetEarningsHistoryOutputBoundary; +import use_case.earnings_history.GetEarningsHistoryOutputData; + +import java.util.List; + +public class EarningsHistoryPresenter implements GetEarningsHistoryOutputBoundary { + + private final EarningsHistoryViewModel viewModel; + + public EarningsHistoryPresenter(EarningsHistoryViewModel viewModel) { + this.viewModel = viewModel; + } + + @Override + public void prepareSuccessView(GetEarningsHistoryOutputData outputData) { + EarningsHistoryState state = new EarningsHistoryState(viewModel.getState()); + List records = outputData.getRecords(); + + state.setRecords(records); + state.setMessage("", EarningsHistoryState.MessageType.NONE); + state.setLoading(false); + + viewModel.setState(state); + viewModel.firePropertyChanged(); + } + + @Override + public void prepareSymbolErrorView(String errorMessage) { + EarningsHistoryState state = new EarningsHistoryState(viewModel.getState()); + state.setRecords(List.of()); + state.setMessage(errorMessage, EarningsHistoryState.MessageType.ERROR); + state.setLoading(false); + + viewModel.setState(state); + viewModel.firePropertyChanged(); + } + + @Override + public void prepareNoDataView(String message) { + EarningsHistoryState state = new EarningsHistoryState(viewModel.getState()); + state.setRecords(List.of()); + state.setMessage(message, EarningsHistoryState.MessageType.WARNING); + state.setLoading(false); + + viewModel.setState(state); + viewModel.firePropertyChanged(); + } + + @Override + public void prepareConnectionErrorView(String message) { + EarningsHistoryState state = new EarningsHistoryState(viewModel.getState()); + state.setMessage(message, EarningsHistoryState.MessageType.ERROR); + state.setLoading(false); + + viewModel.setState(state); + viewModel.firePropertyChanged(); + } +} diff --git a/src/main/java/interface_adapter/earnings_history/EarningsHistoryState.java b/src/main/java/interface_adapter/earnings_history/EarningsHistoryState.java new file mode 100644 index 00000000..1b91b540 --- /dev/null +++ b/src/main/java/interface_adapter/earnings_history/EarningsHistoryState.java @@ -0,0 +1,65 @@ +package interface_adapter.earnings_history; + +import entity.EarningsRecord; + +import java.util.ArrayList; +import java.util.List; + +public class EarningsHistoryState { + + public enum MessageType { NONE, INFO, WARNING, ERROR } + + private String symbol = ""; + private List records = new ArrayList<>(); + private String message = ""; + private MessageType messageType = MessageType.NONE; + private boolean loading = false; + + public EarningsHistoryState() { + } + + public EarningsHistoryState(EarningsHistoryState copy) { + this.symbol = copy.symbol; + this.records = new ArrayList<>(copy.records); + this.message = copy.message; + this.messageType = copy.messageType; + this.loading = copy.loading; + } + + public String getSymbol() { + return symbol; + } + + public void setSymbol(String symbol) { + this.symbol = symbol; + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records; + } + + public String getMessage() { + return message; + } + + public MessageType getMessageType() { + return messageType; + } + + public void setMessage(String message, MessageType type) { + this.message = message; + this.messageType = type; + } + + public boolean isLoading() { + return loading; + } + + public void setLoading(boolean loading) { + this.loading = loading; + } +} diff --git a/src/main/java/interface_adapter/earnings_history/EarningsHistoryViewModel.java b/src/main/java/interface_adapter/earnings_history/EarningsHistoryViewModel.java new file mode 100644 index 00000000..d9fc8fa5 --- /dev/null +++ b/src/main/java/interface_adapter/earnings_history/EarningsHistoryViewModel.java @@ -0,0 +1,34 @@ +package interface_adapter.earnings_history; + +import interface_adapter.ViewModel; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; + +public class EarningsHistoryViewModel extends ViewModel { + + public static final String TITLE_LABEL = "Company Earnings"; + + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + private EarningsHistoryState state = new EarningsHistoryState(); + + public EarningsHistoryViewModel() { + super("earnings history"); + } + + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + public EarningsHistoryState getState() { + return state; + } + + public void setState(EarningsHistoryState state) { + this.state = state; + } + + public void firePropertyChanged() { + support.firePropertyChange("state", null, this.state); + } +} diff --git a/src/main/java/interface_adapter/filter_search/FilterSearchController.java b/src/main/java/interface_adapter/filter_search/FilterSearchController.java new file mode 100644 index 00000000..0bb2e658 --- /dev/null +++ b/src/main/java/interface_adapter/filter_search/FilterSearchController.java @@ -0,0 +1,25 @@ +package interface_adapter.filter_search; + +import use_case.filter_search.FilterSearchInputBoundary; +import use_case.filter_search.FilterSearchRequest; + +public class FilterSearchController { + + private final FilterSearchInputBoundary interactor; + + public FilterSearchController(FilterSearchInputBoundary filterSearchInputBoundary) { + this.interactor = filterSearchInputBoundary; + } + + /** + * Executes the Change Password Use Case. + * @param exchange the exchange + * @param mic the mic + * @param securityType the security type + * @param currency the currency + */ + public void loadStocks(String exchange, String mic, String securityType, String currency) { + final FilterSearchRequest request = new FilterSearchRequest(exchange, mic, securityType, currency); + interactor.execute(request); + } +} diff --git a/src/main/java/interface_adapter/filter_search/FilterSearchPresenter.java b/src/main/java/interface_adapter/filter_search/FilterSearchPresenter.java new file mode 100644 index 00000000..bb669463 --- /dev/null +++ b/src/main/java/interface_adapter/filter_search/FilterSearchPresenter.java @@ -0,0 +1,38 @@ +package interface_adapter.filter_search; + +import entity.Stock; +import use_case.filter_search.FilterSearchOutputBoundary; +import use_case.filter_search.FilterSearchResponse; +import java.util.List; +/** + * The Presenter for the Filter Search Use Case. + */ + +public class FilterSearchPresenter implements FilterSearchOutputBoundary{ + private final FilterSearchViewModel filterSearchViewModel; + + public FilterSearchPresenter(FilterSearchViewModel filterSearchViewModel) { + this.filterSearchViewModel = filterSearchViewModel; + } + + @Override + public void prepareSuccessView(FilterSearchResponse response) { + List stocks = response.getStocks(); + System.out.println("PRESENTER: response stocks = " + + (stocks == null ? "null" : stocks.size())); + + filterSearchViewModel.setStocks(stocks); + + filterSearchViewModel.setErrorMessage(null); + filterSearchViewModel.firePropertyChange(); + } + + @Override + public void prepareFailView(String message) { + System.out.println("PRESENTER: fail view, message = " + message); + filterSearchViewModel.setStocks(null); + filterSearchViewModel.setErrorMessage(message); + filterSearchViewModel.firePropertyChange(); + } + +} diff --git a/src/main/java/interface_adapter/filter_search/FilterSearchState.java b/src/main/java/interface_adapter/filter_search/FilterSearchState.java new file mode 100644 index 00000000..123c39c1 --- /dev/null +++ b/src/main/java/interface_adapter/filter_search/FilterSearchState.java @@ -0,0 +1,37 @@ +package interface_adapter.filter_search; + +import entity.Stock; +import java.util.List; + +public class FilterSearchState { + // List of stocks to display in the table + private List stocks; + + // Error message to show in a dialog (null or empty if no error) + private String errorMessage; + + public FilterSearchState() { + } + + // Optional copy constructor (useful if your ViewModel copies state) + public FilterSearchState(FilterSearchState copy) { + this.stocks = copy.stocks; + this.errorMessage = copy.errorMessage; + } + + public List getStocks() { + return stocks; + } + + public void setStocks(List stocks) { + this.stocks = stocks; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/interface_adapter/filter_search/FilterSearchViewModel.java b/src/main/java/interface_adapter/filter_search/FilterSearchViewModel.java new file mode 100644 index 00000000..0120d4c1 --- /dev/null +++ b/src/main/java/interface_adapter/filter_search/FilterSearchViewModel.java @@ -0,0 +1,38 @@ +package interface_adapter.filter_search; + +import entity.Stock; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.util.List; + +/** + * The View Model for the Filter Search View. + */ + +public class FilterSearchViewModel { + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + + private final String viewName = "Filter Search"; + + private List stocks; + private String errorMessage; + + public String getViewName() { return viewName; } + + public List getStocks() { return stocks; } + + public void setStocks(List stocks) { this.stocks = stocks; } + + public String getErrorMessage() { return errorMessage; } + + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public void firePropertyChange() { + support.firePropertyChange("filterSearch", null, null); + } + + public void addPropertyChangeListener(PropertyChangeListener l) { + support.addPropertyChangeListener(l); + } +} diff --git a/src/main/java/interface_adapter/market_status/MarketStatusController.java b/src/main/java/interface_adapter/market_status/MarketStatusController.java new file mode 100644 index 00000000..eb116db7 --- /dev/null +++ b/src/main/java/interface_adapter/market_status/MarketStatusController.java @@ -0,0 +1,16 @@ +package interface_adapter.market_status; + +import use_case.market_status.MarketStatusInputBoundary; + +public class MarketStatusController { + + private final MarketStatusInputBoundary interactor; + + public MarketStatusController(MarketStatusInputBoundary interactor) { + this.interactor = interactor; + } + + public void updateStatus() { + interactor.execute(); + } +} diff --git a/src/main/java/interface_adapter/market_status/MarketStatusPresenter.java b/src/main/java/interface_adapter/market_status/MarketStatusPresenter.java new file mode 100644 index 00000000..1b4db2dc --- /dev/null +++ b/src/main/java/interface_adapter/market_status/MarketStatusPresenter.java @@ -0,0 +1,59 @@ +package interface_adapter.market_status; + +import use_case.market_status.MarketStatusOutputBoundary; +import use_case.market_status.MarketStatusResponseModel; +import entity.MarketStatus; + +public class MarketStatusPresenter implements MarketStatusOutputBoundary { + private final MarketStatusViewModel viewModel; + + public MarketStatusPresenter(MarketStatusViewModel viewModel) { + this.viewModel = viewModel; + } + + @Override + public void prepareSuccessView(MarketStatusResponseModel responseModel) { + MarketStatus status = responseModel.getMarketStatus(); + + boolean open = status.isOpen(); + String session = status.getSession(); + String holiday = status.getHoliday(); + + // Status message + String text; + if (open) { + if (session != null && !session.isBlank()) { + text = "US market is OPEN (" + session + ")"; + } else { + text = "US market is OPEN"; + } + } else { + if (holiday != null && !holiday.isBlank()) { + text = "US market is CLOSED (Holiday: " + holiday + ")"; + } else { + text = "US market is CLOSED"; + } + } + + viewModel.setStatusText(text); + viewModel.setOpen(open); + viewModel.setSession(session); + viewModel.setHoliday(holiday); + viewModel.setTimezone(status.getTimezone()); + viewModel.setErrorMessage(null); + + viewModel.firePropertyChanged(); + } + + @Override + public void prepareFailView(String errorMessage) { + viewModel.setStatusText("Market status unavailable"); + viewModel.setOpen(false); + viewModel.setSession(null); + viewModel.setHoliday(null); + viewModel.setTimezone(null); + viewModel.setErrorMessage(errorMessage); + + viewModel.firePropertyChanged(); + } +} diff --git a/src/main/java/interface_adapter/market_status/MarketStatusViewModel.java b/src/main/java/interface_adapter/market_status/MarketStatusViewModel.java new file mode 100644 index 00000000..c42a874b --- /dev/null +++ b/src/main/java/interface_adapter/market_status/MarketStatusViewModel.java @@ -0,0 +1,88 @@ +package interface_adapter.market_status; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; + +public class MarketStatusViewModel { + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + + // What is shown in UI + private String statusText; + + // Whether the market is open + private boolean open; + + // Extra info, optional + private String session; // "pre", "regular", "post", etc. + private String exchange; // e.g. "US" + private String holiday; // holiday name, or "" + private String timezone; // e.g. "America/New_York"; + + private String errorMessage; + + // --- observer methods --- + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + public void firePropertyChanged() { + support.firePropertyChange("state", null, this); + } + + // getters + public String getStatusText() { + return statusText; + } + + public void setStatusText(String statusText) { + this.statusText = statusText; + } + + public boolean isOpen() { + return open; + } + + public void setOpen(boolean open) { + this.open = open; + } + + public String getSession() { + return session; + } + + public void setSession(String session) { + this.session = session; + } + + public String getExchange() { + return exchange; + } + + public void setExchange(String exchange) { + this.exchange = exchange; + } + + public String getHoliday() { + return holiday; + } + + public void setHoliday(String holiday) { + this.holiday = holiday; + } + + public String getTimezone() { + return timezone; + } + + public void setTimezone(String timezone) { + this.timezone = timezone; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/interface_adapter/news/NewsController.java b/src/main/java/interface_adapter/news/NewsController.java new file mode 100644 index 00000000..c77052f4 --- /dev/null +++ b/src/main/java/interface_adapter/news/NewsController.java @@ -0,0 +1,31 @@ +package interface_adapter.news; + +import use_case.news.NewsInputBoundary; +import use_case.news.NewsRequestModel; + +/** + * read from UI + * build a NewsRequestModel + * "send" the request through input boundary + */ +public class NewsController { + + private final NewsInputBoundary interactor; + + public NewsController(NewsInputBoundary interactor) { + this.interactor = interactor; + } + + // Load general market news (category=general) + public void loadMarketNews() { + NewsRequestModel request = new NewsRequestModel(); // market news ctor + interactor.executeMarketNews(request); + } + + // Load company-specific news (for a symbol like "AAPL") + public void loadCompanyNews(String symbol, String fromDate, String toDate) { + NewsRequestModel request = new NewsRequestModel(symbol, fromDate, toDate); + interactor.executeCompanyNews(request); + } +} + diff --git a/src/main/java/interface_adapter/news/NewsPresenter.java b/src/main/java/interface_adapter/news/NewsPresenter.java new file mode 100644 index 00000000..add0a3c5 --- /dev/null +++ b/src/main/java/interface_adapter/news/NewsPresenter.java @@ -0,0 +1,32 @@ +package interface_adapter.news; + +import use_case.news.NewsOutputBoundary; +import use_case.news.NewsResponseModel; + +/** + * receives output data from interactor + * writes the data through ViewModel nad then update the state + */ +public class NewsPresenter implements NewsOutputBoundary { + + private final NewsViewModel viewModel; + + public NewsPresenter(NewsViewModel viewModel) { + this.viewModel = viewModel; + } + + @Override + public void prepareSuccessView(NewsResponseModel responseModel) { + viewModel.setArticles(responseModel.getArticles()); + viewModel.setErrorMessage(null); + viewModel.firePropertyChanged(); + } + + @Override + public void prepareFailView(String errorMessage) { + viewModel.setArticles(null); + viewModel.setErrorMessage(errorMessage); + viewModel.firePropertyChanged(); + } +} + diff --git a/src/main/java/interface_adapter/news/NewsViewModel.java b/src/main/java/interface_adapter/news/NewsViewModel.java new file mode 100644 index 00000000..794b56c8 --- /dev/null +++ b/src/main/java/interface_adapter/news/NewsViewModel.java @@ -0,0 +1,47 @@ +package interface_adapter.news; + +import entity.NewsArticle; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.util.List; + +/** + * The Presenter can update the states, so the View will changer correspondingly + */ +public class NewsViewModel { + + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + + // UI state fields (were in NewsState before) + private List articles; + private String errorMessage; + + // listeners + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + public void firePropertyChanged() { + // property name can be anything, keep "state" to avoid changing other code too much + support.firePropertyChange("state", null, this); + } + + + public List getArticles() { + return articles; + } + + public void setArticles(List articles) { + this.articles = articles; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} + diff --git a/src/main/java/interface_adapter/stock_search/StockSearchController.java b/src/main/java/interface_adapter/stock_search/StockSearchController.java new file mode 100644 index 00000000..dec31ae2 --- /dev/null +++ b/src/main/java/interface_adapter/stock_search/StockSearchController.java @@ -0,0 +1,27 @@ +package interface_adapter.stock_search; + +import use_case.stock_search.StockSearchInputBoundary; +import use_case.stock_search.StockSearchRequestModel; + +public class StockSearchController { + + private final StockSearchInputBoundary interactor; + private final StockSearchViewModel viewModel; + + public StockSearchController(StockSearchInputBoundary interactor, + StockSearchViewModel viewModel) { + this.interactor = interactor; + this.viewModel = viewModel; + } + + public void search(String symbol) { + viewModel.setLoading(true); + viewModel.setErrorMessage(null); + viewModel.firePropertyChanged(); + + new Thread(() -> { + StockSearchRequestModel request = new StockSearchRequestModel(symbol); + interactor.execute(request); + }).start(); + } +} diff --git a/src/main/java/interface_adapter/stock_search/StockSearchPresenter.java b/src/main/java/interface_adapter/stock_search/StockSearchPresenter.java new file mode 100644 index 00000000..275c2b73 --- /dev/null +++ b/src/main/java/interface_adapter/stock_search/StockSearchPresenter.java @@ -0,0 +1,64 @@ +package interface_adapter.stock_search; + +import use_case.stock_search.StockSearchOutputBoundary; +import use_case.stock_search.StockSearchResponseModel; + +public class StockSearchPresenter implements StockSearchOutputBoundary { + + private final StockSearchViewModel viewModel; + + public StockSearchPresenter(StockSearchViewModel viewModel) { + this.viewModel = viewModel; + } + + @Override + public void prepareSuccessView(StockSearchResponseModel responseModel) { + StringBuilder sb = new StringBuilder(); + String symbol = responseModel.getSymbol(); + String name = responseModel.getCompanyName(); + String exchange = responseModel.getExchange(); + String industry = responseModel.getIndustry(); + double marketCap = responseModel.getMarketCap(); + + if (name != null && !name.isBlank()) { + sb.append(symbol != null ? symbol : ""); + sb.append(" - "); + sb.append(name); + } else { + sb.append(symbol != null ? symbol : ""); + } + + if (exchange != null && !exchange.isBlank()) { + sb.append(" (").append(exchange).append(")"); + } + sb.append("\n"); + + if (industry != null && !industry.isBlank()) { + sb.append("Industry: ").append(industry).append("\n"); + } + + if (!Double.isNaN(marketCap) && marketCap > 0) { + sb.append("Market Cap: ").append(marketCap).append(" B").append("\n"); + } + + sb.append("\n"); + sb.append("Current: ").append(responseModel.getCurrentPrice()).append("\n"); + sb.append("Open: ").append(responseModel.getOpen()).append("\n"); + sb.append("High: ").append(responseModel.getHigh()).append("\n"); + sb.append("Low: ").append(responseModel.getLow()).append("\n"); + sb.append("Prev: ").append(responseModel.getPreviousClose()).append("\n"); + + viewModel.setInfoText(sb.toString()); + viewModel.setErrorMessage(null); + viewModel.setLoading(false); + viewModel.firePropertyChanged(); + } + + @Override + public void prepareFailView(String errorMessage) { + viewModel.setInfoText(null); + viewModel.setErrorMessage(errorMessage); + viewModel.setLoading(false); + viewModel.firePropertyChanged(); + } +} diff --git a/src/main/java/interface_adapter/stock_search/StockSearchViewModel.java b/src/main/java/interface_adapter/stock_search/StockSearchViewModel.java new file mode 100644 index 00000000..c64be5f9 --- /dev/null +++ b/src/main/java/interface_adapter/stock_search/StockSearchViewModel.java @@ -0,0 +1,45 @@ +package interface_adapter.stock_search; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; + +public class StockSearchViewModel { + + private final PropertyChangeSupport support = new PropertyChangeSupport(this); + + private String infoText; + private String errorMessage; + private boolean loading; + + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + public void firePropertyChanged() { + support.firePropertyChange("state", null, this); + } + + public String getInfoText() { + return infoText; + } + + public void setInfoText(String infoText) { + this.infoText = infoText; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public boolean isLoading() { + return loading; + } + + public void setLoading(boolean loading) { + this.loading = loading; + } +} diff --git a/src/main/java/interface_adapter/trade/TradeController.java b/src/main/java/interface_adapter/trade/TradeController.java new file mode 100644 index 00000000..8284a3cc --- /dev/null +++ b/src/main/java/interface_adapter/trade/TradeController.java @@ -0,0 +1,26 @@ +package interface_adapter.trade; + +import use_case.trade.TradeInputBoundary; +import use_case.trade.TradeRequestModel; + +/** + * Controller for the Trade use case. + */ +public class TradeController { + + private final TradeInputBoundary tradeInteractor; + + public TradeController(TradeInputBoundary tradeInteractor) { + this.tradeInteractor = tradeInteractor; + } + + public void execute(String symbol) { + TradeRequestModel requestModel = new TradeRequestModel(symbol); + tradeInteractor.execute(requestModel); + } + + public void disconnect() { + tradeInteractor.disconnect(); + } +} + diff --git a/src/main/java/interface_adapter/trade/TradePresenter.java b/src/main/java/interface_adapter/trade/TradePresenter.java new file mode 100644 index 00000000..4f091de3 --- /dev/null +++ b/src/main/java/interface_adapter/trade/TradePresenter.java @@ -0,0 +1,36 @@ +package interface_adapter.trade; + +import entity.Trade; +import use_case.trade.TradeOutputBoundary; +import use_case.trade.TradeResponseModel; + +/** + * Presenter for the Trade use case. + */ +public class TradePresenter implements TradeOutputBoundary { + + private final TradeViewModel viewModel; + + public TradePresenter(TradeViewModel viewModel) { + this.viewModel = viewModel; + } + + @Override + public void prepareSuccessView(TradeResponseModel responseModel) { + Trade trade = responseModel.getTrade(); + if (trade != null) { + viewModel.setCurrentTrade(trade); + } + } + + @Override + public void prepareFailView(String errorMessage) { + viewModel.setStatus(errorMessage, true); + } + + @Override + public void prepareStatusView(String statusText, boolean isError) { + viewModel.setStatus(statusText, isError); + } +} + diff --git a/src/main/java/interface_adapter/trade/TradeState.java b/src/main/java/interface_adapter/trade/TradeState.java new file mode 100644 index 00000000..2964b61e --- /dev/null +++ b/src/main/java/interface_adapter/trade/TradeState.java @@ -0,0 +1,49 @@ +package interface_adapter.trade; + +import entity.Trade; + +/** + * State for the Trade view. + */ +public class TradeState { + private Trade currentTrade; + private String statusText = "Status: Disconnected"; + private boolean isError = false; + private String symbol = ""; + + public TradeState() { + } + + public Trade getCurrentTrade() { + return currentTrade; + } + + public void setCurrentTrade(Trade currentTrade) { + this.currentTrade = currentTrade; + } + + public String getStatusText() { + return statusText; + } + + public void setStatusText(String statusText) { + this.statusText = statusText; + } + + public boolean isError() { + return isError; + } + + public void setError(boolean error) { + isError = error; + } + + public String getSymbol() { + return symbol; + } + + public void setSymbol(String symbol) { + this.symbol = symbol; + } +} + diff --git a/src/main/java/interface_adapter/trade/TradeViewModel.java b/src/main/java/interface_adapter/trade/TradeViewModel.java new file mode 100644 index 00000000..108ad399 --- /dev/null +++ b/src/main/java/interface_adapter/trade/TradeViewModel.java @@ -0,0 +1,34 @@ +package interface_adapter.trade; + +import interface_adapter.ViewModel; + +/** + * ViewModel for the Trade view. + */ +public class TradeViewModel extends ViewModel { + + public TradeViewModel() { + super("trade"); + setState(new TradeState()); + } + + public void setCurrentTrade(entity.Trade trade) { + TradeState state = getState(); + state.setCurrentTrade(trade); + firePropertyChange(); + } + + public void setStatus(String statusText, boolean isError) { + TradeState state = getState(); + state.setStatusText(statusText); + state.setError(isError); + firePropertyChange(); + } + + public void setSymbol(String symbol) { + TradeState state = getState(); + state.setSymbol(symbol); + firePropertyChange(); + } +} + diff --git a/src/main/java/use_case/earnings_history/EarningsDataAccessInterface.java b/src/main/java/use_case/earnings_history/EarningsDataAccessInterface.java new file mode 100644 index 00000000..de5ed727 --- /dev/null +++ b/src/main/java/use_case/earnings_history/EarningsDataAccessInterface.java @@ -0,0 +1,19 @@ +package use_case.earnings_history; + +import entity.EarningsRecord; + +import java.io.IOException; +import java.util.List; + +/** + * Gateway used by the interactor to fetch earnings data. + * + * Contract: + * - return null -> symbol not found / unsupported (API returns "error") + * - return empty -> symbol valid but no earnings data + * - throw IOException -> network / HTTP issue + */ +public interface EarningsDataAccessInterface { + + List getEarningsFor(String symbol) throws IOException; +} diff --git a/src/main/java/use_case/earnings_history/GetEarningsHistoryInputBoundary.java b/src/main/java/use_case/earnings_history/GetEarningsHistoryInputBoundary.java new file mode 100644 index 00000000..4ca84dc6 --- /dev/null +++ b/src/main/java/use_case/earnings_history/GetEarningsHistoryInputBoundary.java @@ -0,0 +1,5 @@ +package use_case.earnings_history; + +public interface GetEarningsHistoryInputBoundary { + void execute(GetEarningsHistoryInputData inputData); +} diff --git a/src/main/java/use_case/earnings_history/GetEarningsHistoryInputData.java b/src/main/java/use_case/earnings_history/GetEarningsHistoryInputData.java new file mode 100644 index 00000000..33beecfc --- /dev/null +++ b/src/main/java/use_case/earnings_history/GetEarningsHistoryInputData.java @@ -0,0 +1,14 @@ +package use_case.earnings_history; + +public class GetEarningsHistoryInputData { + + private final String symbol; + + public GetEarningsHistoryInputData(String symbol) { + this.symbol = symbol; + } + + public String getSymbol() { + return symbol; + } +} diff --git a/src/main/java/use_case/earnings_history/GetEarningsHistoryInteractor.java b/src/main/java/use_case/earnings_history/GetEarningsHistoryInteractor.java new file mode 100644 index 00000000..37db09e3 --- /dev/null +++ b/src/main/java/use_case/earnings_history/GetEarningsHistoryInteractor.java @@ -0,0 +1,50 @@ +package use_case.earnings_history; + +import entity.EarningsRecord; + +import java.io.IOException; +import java.util.List; + +public class GetEarningsHistoryInteractor implements GetEarningsHistoryInputBoundary { + + private final EarningsDataAccessInterface dataAccess; + private final GetEarningsHistoryOutputBoundary presenter; + + public GetEarningsHistoryInteractor(EarningsDataAccessInterface dataAccess, + GetEarningsHistoryOutputBoundary presenter) { + this.dataAccess = dataAccess; + this.presenter = presenter; + } + + @Override + public void execute(GetEarningsHistoryInputData inputData) { + String symbol = inputData.getSymbol(); + + if (symbol == null || symbol.trim().isEmpty()) { + presenter.prepareSymbolErrorView("Please enter a symbol."); + return; + } + + symbol = symbol.trim().toUpperCase(); + + try { + // method name matches the interface + List records = dataAccess.getEarningsFor(symbol); + + if (records == null) { + presenter.prepareSymbolErrorView( + "Company not found or symbol not supported.\nPlease check the symbol."); + } else if (records.isEmpty()) { + presenter.prepareNoDataView( + "No earnings data found for this company."); + } else { + GetEarningsHistoryOutputData outputData = + new GetEarningsHistoryOutputData(symbol, records); + presenter.prepareSuccessView(outputData); + } + } catch (IOException e) { + presenter.prepareConnectionErrorView( + "Network error or API issue.\nCheck your internet or API key."); + } + } +} diff --git a/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputBoundary.java b/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputBoundary.java new file mode 100644 index 00000000..9749170f --- /dev/null +++ b/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputBoundary.java @@ -0,0 +1,13 @@ +package use_case.earnings_history; + +public interface GetEarningsHistoryOutputBoundary { + + void prepareSuccessView(GetEarningsHistoryOutputData outputData); + + // These three correspond to the three different JOptionPane cases + void prepareSymbolErrorView(String errorMessage); + + void prepareNoDataView(String message); + + void prepareConnectionErrorView(String message); +} diff --git a/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputData.java b/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputData.java new file mode 100644 index 00000000..1c5fd704 --- /dev/null +++ b/src/main/java/use_case/earnings_history/GetEarningsHistoryOutputData.java @@ -0,0 +1,24 @@ +package use_case.earnings_history; + +import entity.EarningsRecord; + +import java.util.List; + +public class GetEarningsHistoryOutputData { + + private final String symbol; + private final List records; + + public GetEarningsHistoryOutputData(String symbol, List records) { + this.symbol = symbol; + this.records = records; + } + + public String getSymbol() { + return symbol; + } + + public List getRecords() { + return records; + } +} diff --git a/src/main/java/use_case/filter_search/FilterSearchDataAccessInterface.java b/src/main/java/use_case/filter_search/FilterSearchDataAccessInterface.java new file mode 100644 index 00000000..fc9246b6 --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchDataAccessInterface.java @@ -0,0 +1,10 @@ +package use_case.filter_search; + +import entity.Stock; + +import java.util.List; + +public interface FilterSearchDataAccessInterface { + List loadStocks(String exchange, String mic, String securityType, String currency) throws Exception; +} + diff --git a/src/main/java/use_case/filter_search/FilterSearchInputBoundary.java b/src/main/java/use_case/filter_search/FilterSearchInputBoundary.java new file mode 100644 index 00000000..922d666f --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchInputBoundary.java @@ -0,0 +1,125 @@ +package use_case.filter_search; + +/** + * Input Boundary for the Filter Search Use Case. + */ + +public interface FilterSearchInputBoundary { + /** + * Executes the filter search use case. + * @param request the input data + */ + String[] EXCHANGE_OPTIONS = {"US"}; + + String[] MIC_OPTIONS = {null, "XADS", "XAMS", "ASEX", "XASX", "XBUE", "XBOG", "XBUD", "XBER", "XBAH", "XBKK", + "XBOM", "XBRU", "XCAI", "XCNQ", "XCSE", "BVCA", "XCAS", "XDFM", "XETR", "XDUS", "XFRA", "XHEL", "XHKG", + "XHAM", "XICE", "XDUB", "XIST", "XIDX", "XJSE", "XKLS", "XKOS", "XKRX", "XKUW", "XLON", "XLIS", "XMAD", + "MISX", "XMIL", "XMAL", "XMUN", "XMEX", "NEOE", "XNSA", "XNSE", "XNZE", "XOSL", "XMUS", "XPAR", "XPHS", + "XPRA", "DSMD", "XBSE", "XRIS", "BVMF", "XSTU", "XSES", "XSGO", "XSAU", "XSHG", "XSTO", "WSWX", "XSHE", + "WJPX", "XTAE", "XTAL", "XTSE", "XTAI", "FNSE", "ROCO", "XTSX", "XWBO", "HSTC", "XLIT", "XWAR", "XHAN", + "XNYS", "XGAT", "XASE", "BATS", "ARCX", "XNMS", "XNCM", "XNGS", "IEXG", "XNAS", "OTCM", "OOTC", "XSTC", + "XHNX", "FNIS", "FNDK", "NFI"}; + + String[] SECURITY_OPTIONS = {null, "ABS Auto", "ABS Card", "ABS Home", "ABS Other", "ACCEPT BANCARIA", + "ADJ CONV. TO FIXED", "ADJ CONV. TO FIXED, OID", "ADJUSTABLE", "ADJUSTABLE, OID", "ADR", "Agncy ABS Home", + "Agncy ABS Other", "Agncy CMBS", "Agncy CMO FLT", "Agncy CMO INV", "Agncy CMO IO", "Agncy CMO Other", + "Agncy CMO PO", "Agncy CMO Z", "Asset-Based", "ASSET-BASED BRIDGE", "ASSET-BASED BRIDGE REV", + "ASSET-BASED BRIDGE TERM", "ASSET-BASED DELAY-DRAW TERM", "ASSET-BASED DIP", "ASSET-BASED DIP DELAY-DRAW", + "ASSET-BASED DIP REV", "ASSET-BASED DIP TERM", "ASSET-BASED LOC", "ASSET-BASED PIK REV", + "ASSET-BASED PIK TERM", "ASSET-BASED REV", "ASSET-BASED TERM", "AUSTRALIAN", "AUSTRALIAN CD", + "AUSTRALIAN CP", "Austrian Crt", "BANK ACCEPT BILL", "BANK BILL", "BANK NOTE", "BANKERS ACCEPT", + "BANKERS ACCEPTANCE", "BASIS SWAP", "BASIS TRADE ON CLOSE", "Basket WRT", "BDR", "BEARER DEP NOTE", + "Belgium Cert", "BELGIUM CP", "BILL OF EXCHANGE", "BILLET A ORDRE", "Bond", "BRAZIL GENERIC", + "BRAZILIAN CDI", "BRIDGE", "BRIDGE DELAY-DRAW", "BRIDGE DELAY-DRAW TERM", "BRIDGE DIP TERM", + "BRIDGE GUARANTEE FAC", "BRIDGE ISLAMIC", "BRIDGE ISLAMIC TERM", "BRIDGE PIK", "BRIDGE PIK REV", + "BRIDGE PIK TERM", "BRIDGE REV", "BRIDGE REV GUARANTEE FAC", "BRIDGE STANDBY TERM", "BRIDGE TERM", + "BRIDGE TERM GUARANTEE FAC", "BRIDGE TERM VAT-TRNCH", "BRIDGE VAT-TRNCH", "BULLDOG", "BUTTERFLY SWAP", + "CAD INT BEAR CP", "CALC_INSTRUMENT", "Calendar Spread Option", "CALL LOANS", "CALLABLE CP", "CANADIAN", + "Canadian", "CANADIAN CD", "CANADIAN CP", "Canadian DR", "CAPS & FLOORS", "Car Forward", "CASH", + "CASH FLOW", "CASH FLOW, OID", "CASH RATE", "CBLO", "CD", "CDI", "CDR", "CEDEAR", "CF", "CHILEAN CD", + "CHILEAN DN", "Closed-End Fund", "CMBS", "Cmdt Fut WRT", "Cmdt Idx WRT", "COLLAT CALL NOTE", "COLOMBIAN CD", + "COMMERCIAL NOTE", "COMMERCIAL PAPER", "Commodity Index", "Common Stock", "CONTRACT FOR DIFFERENCE", + "CONTRACT FRA", "Conv Bond", "Conv Prfd", "Corp Bnd WRT", "Cover Pool", "CP-LIKE EXT NOTE", "CPI LINKED", + "CROSS", "Crypto", "Currency future.", "Currency option.", "Currency spot.", "Currency WRT", "CURVE_ROLL", + "DELAY-DRAW", "DELAY-DRAW ISLAMIC", "DELAY-DRAW ISLAMIC LOC", "DELAY-DRAW ISLAMIC TERM", "DELAY-DRAW LOC", + "DELAY-DRAW PIK TERM", "DELAY-DRAW STANDBY TERM", "DELAY-DRAW TERM", "DELAY-DRAW TERM GUARANTEE F", + "DELAY-DRAW TERM VAT-TRNCH", "DEPOSIT", "DEPOSIT NOTE", "DIM SUM BRIDGE TERM", "DIM SUM DELAY-DRAW TERM", + "DIM SUM REV", "DIM SUM TERM", "DIP", "DIP DELAY-DRAW ISLAMIC TERM", "DIP DELAY-DRAW PIK TERM", + "DIP DELAY-DRAW TERM", "DIP LOC", "DIP PIK TERM", "DIP REV", "DIP STANDBY LOC", "DIP SYNTH LOC", + "DIP TERM", "DISCOUNT FIXBIS", "DISCOUNT NOTES", "DIVIDEND NEUTRAL STOCK FUTURE", "DOMESTC TIME DEP", + "DOMESTIC", "DOMESTIC MTN", "Dutch Cert", "DUTCH CP", "EDR", "Equity Index", "Equity Option", "Equity WRT", + "ETP", "EURO CD", "EURO CP", "EURO MTN", "EURO NON-DOLLAR", "EURO STRUCTRD LN", "EURO TIME DEPST", + "EURO-DOLLAR", "EURO-ZONE", "EXTEND COMM NOTE", "EXTEND. NOTE MTN", "FDIC", "FED FUNDS", "FIDC", + "Financial commodity future.", "Financial commodity generic.", "Financial commodity option.", + "Financial commodity spot.", "Financial index future.", "Financial index generic.", + "Financial index option.", "FINNISH CD", "FINNISH CP", "FIXED", "Fixed Income Index", "FIXED, OID", + "FIXING RATE", "FLOATING", "FLOATING CP", "FLOATING, OID", "FNMA FHAVA", "Foreign Sh.", "FORWARD", + "FORWARD CROSS", "FORWARD CURVE", "FRA", "FRENCH CD", "French Cert", "FRENCH CP", "Fund of Funds", + "Futures Monthly Ticker", "FWD SWAP", "FX Curve", "FX DISCOUNT NOTE", "GDR", "Generic currency future.", + "Generic index future.", "German Cert", "GERMAN CP", "GLOBAL", "GUARANTEE FAC", "HB", "HDR", "HONG KONG CD", + "I.R. Fut WRT", "I.R. Swp WRT", "IDR", "IMM FORWARD", "IMM SWAP", "Index", "Index Option", "Index WRT", + "INDIAN CD", "INDIAN CP", "INDONESIAN CP", "Indx Fut WRT", "INFLATION SWAP", "INT BEAR FIXBIS", + "Int. Rt. WRT", "INTER. APPRECIATION", "INTER. APPRECIATION, OID", "ISLAMIC", "ISLAMIC BA", "ISLAMIC CP", + "ISLAMIC GUARANTEE FAC", "ISLAMIC LOC", "ISLAMIC REV", "ISLAMIC STANDBY", "ISLAMIC STANDBY REV", + "ISLAMIC STANDBY TERM", "ISLAMIC TERM", "ISLAMIC TERM GUARANTEE FAC", "ISLAMIC TERM VAT-TRNCH", "JUMBO CD", + "KOREAN CD", "KOREAN CP", "LEBANESE CP", "LIQUIDITY NOTE", "LOC", "LOC GUARANTEE FAC", "LOC TERM", + "Ltd Part", "MALAYSIAN CP", "Managed Account", "MARGIN TERM DEP", "MASTER NOTES", "MBS 10yr", "MBS 15yr", + "MBS 20yr", "MBS 30yr", "MBS 35yr", "MBS 40yr", "MBS 50yr", "MBS 5yr", "MBS 7yr", "MBS ARM", "MBS balloon", + "MBS Other", "MED TERM NOTE", "MEDIUM TERM CD", "MEDIUM TERM ECD", "MEXICAN CP", "MEXICAN PAGARE", "Misc.", + "MLP", "MONETARY BILLS", "MONEY MARKET CALL", "MUNI CP", "MUNI INT BEAR CP", "MUNI SWAP", "MURABAHA", + "Mutual Fund", "MV", "MX CERT BURSATIL", "NDF SWAP", "NEG EURO CP", "NEG INST DEPOSIT", "NEGOTIABLE CD", + "NEW ZEALAND CD", "NEW ZEALAND CP", "NON-DELIVERABLE FORWARD", "NON-DELIVERABLE IRS SWAP", "NVDR", + "NY Reg Shrs", "OID", "ONSHORE FORWARD", "ONSHORE SWAP", "Open-End Fund", "OPTION", + "Option on Equity Future", "OPTION VOLATILITY", "OTHER", "OVER/NIGHT", "OVERDRAFT", + "OVERNIGHT INDEXED SWAP", "PANAMANIAN CP", "Participate Cert", "PHILIPPINE CP", + "Physical commodity forward.", "Physical commodity future.", "Physical commodity generic.", + "Physical commodity option.", "Physical commodity spot.", "Physical index future.", + "Physical index option.", "PIK", "PIK LOC", "PIK REV", "PIK SYNTH LOC", "PIK TERM", "PLAZOS FIJOS", + "PORTUGUESE CP", "Preference", "Preferred", "PRES", "Prfd WRT", "PRIV PLACEMENT", "PRIVATE", "Private Comp", + "Private-equity backed", "PROMISSORY NOTE", "PROV T-BILL", "Prvt CMBS", "Prvt CMO FLT", "Prvt CMO INV", + "Prvt CMO IO", "Prvt CMO Other", "Prvt CMO PO", "Prvt CMO Z", "PUBLIC", "Pvt Eqty Fund", "RDC", "Receipt", + "REIT", "REPO", "RESERVE-BASED DIP REV", "RESERVE-BASED LOC", "RESERVE-BASED REV", "RESERVE-BASED TERM", + "RESTRUCTURD DEBT", "RETAIL CD", "RETURN IDX", "REV", "REV GUARANTEE FAC", "REV VAT-TRNCH", "Revolver", + "Right", "Royalty Trst", "S.TERM LOAN NOTE", "SAMURAI", "Savings Plan", "Savings Share", "SBA Pool", "SDR", + "Sec Lending", "SHOGUN", "SHORT TERM BN", "SHORT TERM DN", "SINGAPORE CP", "Singapore DR", + "SINGLE STOCK DIVIDEND FUTURE", "SINGLE STOCK FORWARD", "SINGLE STOCK FUTURE", "SINGLE STOCK FUTURE SPREAD", + "SN", "SPANISH CP", "SPECIAL LMMK PGM", "SPOT", "Spot index.", "STANDBY", "STANDBY LOC", + "STANDBY LOC GUARANTEE FAC", "STANDBY REV", "STANDBY TERM", "Stapled Security", "STERLING CD", + "STERLING CP", "Strategy Trade.", "SWAP" , "SWAP SPREAD" , "SWAPTION VOLATILITY" , "SWEDISH CP" , + "SWINGLINE" , "Swiss Cert" , "SYNTH LOC" , "SYNTH REV" , "SYNTH TERM" , "Synthetic Term" , "TAIWAN CP" , + "TAIWAN CP GUAR" , "TAIWAN NEGO CD" , "TAIWAN TIME DEPO" , "TAX CREDIT" , "TAX CREDIT, OID" , "TDR" , + "TERM" , "TERM DEPOSITS" , "TERM GUARANTEE FAC" , "TERM REV" , "TERM VAT-TRNCH" , "TEST" , "THAILAND CP" , + "TLTRO TERM" , "Tracking Stk" , "TREASURY BILL" , "U.S. CD" , "U.S. CP" , "U.S. INT BEAR CP" , "UIT" , + "UK GILT STOCK" , "UMBS MBS Other" , "Unit" , "Unit Inv Tst" , "UNITRANCHE" , "UNITRANCHE ASSET-BASED REV", + "UNITRANCHE DELAY-DRAW PIK T" , "UNITRANCHE DELAY-DRAW TERM" , "UNITRANCHE PIK REV" , "UNITRANCHE PIK TERM", + "UNITRANCHE REV" , "UNITRANCHE TERM" , "US DOMESTIC" , "US GOVERNMENT" , "US NON-DOLLAR" , + "VAR RATE DEM OBL" , "VAT-TRNCH" , "VENEZUELAN CP" , "VIETNAMESE CD" , "VOLATILITY DERIVATIVE" , "Warrant", + "YANKEE" , "YANKEE CD" , "YEN CD" , "YEN CP" , "Yield Curve" , "ZERO COUPON" , "ZERO COUPON, OID"}; + + public final String[] CURRENCY_OPTIONS = {null, "ADP", "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "ATS", "AUD", + "AUd", "AWG", "AZM", "AZN", "BAM", "BBD", "BDT", "BEF", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", + "BRl", "BSD", "BTN", "BWP", "BWp", "BYN", "BYR", "BYS", "BZD", "CAD", "CAd", "CDF", "CER", "CHF", "CHf", + "CLF", "CLP", "CNH", "CNT", "CNY", "COP", "COU", "CRC", "CRS", "CUP", "CVE", "CYP", "CZK", "DEM", "DJF", + "DKK", "DOP", "DZD", "ECS", "EEK", "EES", "EGD", "EGP", "ERN", "ESP", "ETB", "EUA", "EUR", "EUr", "FIM", + "FJD", "FKP", "FRF", "GBP", "GBp", "GEL", "GHC", "GHS", "GIP", "GLD", "GMD", "GNF", "GRD", "GTQ", "GWP", + "GYD", "HKD", "HNL", "HRK", "HTG", "HUF", "IDR", "IEP", "ILS", "ILs", "INR", "IQD", "IRR", "ISK", "ITL", + "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KWd", "KYD", "KZT", "LAK", + "LBP", "LKR", "LRD", "LSL", "LTL", "LUF", "LVL", "LYD", "MAD", "MDL", "MGA", "MGF", "MKD", "MLF", "MMK", + "MNT", "MOP", "MRO", "MRU", "MTL", "MULTI", "MUR", "MVR", "MWK", "MWk", "MXN", "MYR", "MYr", "MZM", "MZN", + "NAD", "NAd", "NGN", "NIC", "NID", "NIO", "NLG", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", + "PKR", "PLD", "PLN", "PLT", "PTE", "PYG", "QAR", "ROL", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", + "SDD", "SDG", "SDP", "SDR", "SEK", "SGD", "SGd", "SHP", "SIT", "SKK", "SLE", "SLL", "SLV", "SOS", "SPL", + "SRD", "SRG", "SSP", "STD", "STN", "SVC", "SYP", "SZL", "SZl", "THB", "THO", "TJS", "TMM", "TMT", "TND", + "TOP", "TPE", "TRL", "TRY", "TTD", "TVD", "TWD", "TZS", "UAH", "UDI", "UGX", "US", "USD", "USd", "UVR", + "UYI", "UYU", "UYW", "UZS", "VEB", "VEE", "VEF", "VES", "VND", "VUV", "WST", "X0S", "X1S", "X2S", "X3S", + "X4S", "X5S", "X6S", "X7S", "X8S", "X9S", "XAD", "XAF", "XAG", "XAL", "XAO", "XAS", "XAU", "XAV", "XBA", + "XBI", "XBN", "XBS", "XBT", "XBW", "XCD", "XCG", "XCR", "XCS", "XCU", "XDG", "XDH", "XDI", "XDO", "XDR", + "XDT", "XEG", "XEN", "XEO", "XET", "XEU", "XFI", "XFL", "XFM", "XFT", "XGZ", "XHB", "XIC", "XIN", "XIO", + "XLC", "XLI", "XLM", "XLU", "XMA", "XMK", "XMN", "XMR", "XNI", "XOF", "XPB", "XPD", "XPF", "XPT", "XRA", + "XRH", "XRI", "XRP", "XRU", "XSA", "XSN", "XSO", "XST", "XSU", "XSZ", "XTH", "XTK", "XTR", "XUC", "XUN", + "XUT", "XVC", "XVV", "XXT", "XZC", "XZI", "YER", "ZAR", "ZAr", "ZMK", "ZMW", "ZWD", "ZWd", "ZWF", "ZWG", + "ZWg", "ZWL", "ZWN", "ZWR"}; + + + void execute(FilterSearchRequest request); +} diff --git a/src/main/java/use_case/filter_search/FilterSearchInteractor.java b/src/main/java/use_case/filter_search/FilterSearchInteractor.java new file mode 100644 index 00000000..57980d39 --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchInteractor.java @@ -0,0 +1,47 @@ +package use_case.filter_search; + +import entity.Stock; + +import java.util.List; + +/** + * The Filter Search Interactor. + */ + +public class FilterSearchInteractor implements FilterSearchInputBoundary { + private final FilterSearchDataAccessInterface filterSearchDataAccess; + private final FilterSearchOutputBoundary filterSearchPresenter; + + public FilterSearchInteractor(FilterSearchDataAccessInterface data, + FilterSearchOutputBoundary filterSearchOutputBoundary) { + this.filterSearchDataAccess = data; + this.filterSearchPresenter = filterSearchOutputBoundary; + } + + @Override + public void execute(FilterSearchRequest filterSearchRequest) { + final String exchange = filterSearchRequest.getExchange(); + final String mic = filterSearchRequest.getMic(); + final String securityType = filterSearchRequest.getSecurityType(); + final String currency = filterSearchRequest.getCurrency(); + + try { + List stocks = + filterSearchDataAccess.loadStocks(exchange, mic, securityType, currency); + + System.out.println("INTERACTOR: stocks from DAO = " + + (stocks == null ? "null" : stocks.size())); + + FilterSearchResponse response = new FilterSearchResponse(stocks); + filterSearchPresenter.prepareSuccessView(response); + } + + catch (Exception e) { + // Debug output in console: + e.printStackTrace(); + + filterSearchPresenter.prepareFailView("Unable to load filtered stocks." + + e.getMessage()); + } + } +} diff --git a/src/main/java/use_case/filter_search/FilterSearchOutputBoundary.java b/src/main/java/use_case/filter_search/FilterSearchOutputBoundary.java new file mode 100644 index 00000000..6d2c7fac --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchOutputBoundary.java @@ -0,0 +1,15 @@ +package use_case.filter_search; + +/** + * The output boundary for the Filter Search Use Case. + */ + +public interface FilterSearchOutputBoundary { + /** + * Prepares the success view for the Filter Search Use Case. + * @param filterSearchResponse the output data + */ + void prepareSuccessView(FilterSearchResponse filterSearchResponse); + + void prepareFailView(String s); +} diff --git a/src/main/java/use_case/filter_search/FilterSearchRequest.java b/src/main/java/use_case/filter_search/FilterSearchRequest.java new file mode 100644 index 00000000..61b68f0b --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchRequest.java @@ -0,0 +1,32 @@ +package use_case.filter_search; + +public class FilterSearchRequest { + private final String exchange; + private final String mic; + private final String securityType; + private final String currency; + + public FilterSearchRequest(String exchange) { + this.exchange = exchange; + this.mic = null; + this.securityType = null; + this.currency = null; + } + + public FilterSearchRequest(String exchange, String mic, String securityType, String currency) { + this.exchange = exchange; + this.mic = mic; + this.securityType = securityType; + this.currency = currency; + + } + + public String getExchange() {return this.exchange;} + + public String getMic() {return this.mic;} + + public String getSecurityType() {return this.securityType;} + + public String getCurrency() {return this.currency;} + +} diff --git a/src/main/java/use_case/filter_search/FilterSearchResponse.java b/src/main/java/use_case/filter_search/FilterSearchResponse.java new file mode 100644 index 00000000..2063001e --- /dev/null +++ b/src/main/java/use_case/filter_search/FilterSearchResponse.java @@ -0,0 +1,19 @@ +package use_case.filter_search; + +import entity.Stock; + +import java.util.List; + +public class FilterSearchResponse { + private final List stocks; + + public FilterSearchResponse(List stocks) { + System.out.println("RESPONSE ctor: incoming stocks = " + + (stocks == null ? "null" : stocks.size())); + this.stocks = stocks; + } + + public List getStocks() { + return stocks; + } +} diff --git a/src/main/java/use_case/market_status/MarketStatusDataAccessInterface.java b/src/main/java/use_case/market_status/MarketStatusDataAccessInterface.java new file mode 100644 index 00000000..9e382973 --- /dev/null +++ b/src/main/java/use_case/market_status/MarketStatusDataAccessInterface.java @@ -0,0 +1,7 @@ +package use_case.market_status; + +import entity.MarketStatus; + +public interface MarketStatusDataAccessInterface { + MarketStatus loadStatus() throws Exception; +} diff --git a/src/main/java/use_case/market_status/MarketStatusInputBoundary.java b/src/main/java/use_case/market_status/MarketStatusInputBoundary.java new file mode 100644 index 00000000..8f5fbe90 --- /dev/null +++ b/src/main/java/use_case/market_status/MarketStatusInputBoundary.java @@ -0,0 +1,6 @@ +package use_case.market_status; + +public interface MarketStatusInputBoundary { + void execute(); +} + diff --git a/src/main/java/use_case/market_status/MarketStatusInteractor.java b/src/main/java/use_case/market_status/MarketStatusInteractor.java new file mode 100644 index 00000000..fb1d71b4 --- /dev/null +++ b/src/main/java/use_case/market_status/MarketStatusInteractor.java @@ -0,0 +1,32 @@ +package use_case.market_status; + +import entity.MarketStatus; + +public class MarketStatusInteractor implements MarketStatusInputBoundary{ + private final MarketStatusDataAccessInterface dataAccess; + private final MarketStatusOutputBoundary presenter; + + public MarketStatusInteractor(MarketStatusDataAccessInterface dataAccess, + MarketStatusOutputBoundary presenter) { + this.dataAccess = dataAccess; + this.presenter = presenter; + } + + @Override + public void execute() { + try { + MarketStatus status = dataAccess.loadStatus(); + + if (status == null) { + presenter.prepareFailView("No market status available."); + return; + } + + MarketStatusResponseModel response = new MarketStatusResponseModel(status); + presenter.prepareSuccessView(response); + + } catch (Exception e) { + presenter.prepareFailView("Unable to load market status: " + e.getMessage()); + } + } +} diff --git a/src/main/java/use_case/market_status/MarketStatusOutputBoundary.java b/src/main/java/use_case/market_status/MarketStatusOutputBoundary.java new file mode 100644 index 00000000..0b50a594 --- /dev/null +++ b/src/main/java/use_case/market_status/MarketStatusOutputBoundary.java @@ -0,0 +1,8 @@ +package use_case.market_status; + +public interface MarketStatusOutputBoundary { + + void prepareSuccessView(MarketStatusResponseModel responseModel); + + void prepareFailView(String errorMessage); +} \ No newline at end of file diff --git a/src/main/java/use_case/market_status/MarketStatusResponseModel.java b/src/main/java/use_case/market_status/MarketStatusResponseModel.java new file mode 100644 index 00000000..75f938de --- /dev/null +++ b/src/main/java/use_case/market_status/MarketStatusResponseModel.java @@ -0,0 +1,14 @@ +package use_case.market_status; + +import entity.MarketStatus; + +public class MarketStatusResponseModel { + + private final MarketStatus marketStatus; + + public MarketStatusResponseModel(MarketStatus marketStatus) { + this.marketStatus = marketStatus; + } + + public MarketStatus getMarketStatus() {return marketStatus;} +} \ No newline at end of file diff --git a/src/main/java/use_case/news/NewsDataAccessInterface.java b/src/main/java/use_case/news/NewsDataAccessInterface.java new file mode 100644 index 00000000..70b49552 --- /dev/null +++ b/src/main/java/use_case/news/NewsDataAccessInterface.java @@ -0,0 +1,17 @@ +package use_case.news; + +import java.util.List; +import entity.NewsArticle; + +public interface NewsDataAccessInterface { + + // Maps to Finnhub endpoint: /news?category=general + List loadMarketNews() throws Exception; + + //Maps to Finnhub endpoint: + // /company-news?symbol={symbol}&from={fromDate}&to={toDate} + // Dates are "YYYY-MM-DD". + List loadCompanyNews(String symbol, + String fromDate, + String toDate) throws Exception; +} \ No newline at end of file diff --git a/src/main/java/use_case/news/NewsInputBoundary.java b/src/main/java/use_case/news/NewsInputBoundary.java new file mode 100644 index 00000000..50bc751f --- /dev/null +++ b/src/main/java/use_case/news/NewsInputBoundary.java @@ -0,0 +1,12 @@ +package use_case.news; + +/** + * Define what actions this user case supports + */ +public interface NewsInputBoundary { + // Load general market news (category=general). + void executeMarketNews(NewsRequestModel requestModel); + + // Load company-specific news for the given symbol. + void executeCompanyNews(NewsRequestModel requestModel); +} diff --git a/src/main/java/use_case/news/NewsInteractor.java b/src/main/java/use_case/news/NewsInteractor.java new file mode 100644 index 00000000..ad767ac5 --- /dev/null +++ b/src/main/java/use_case/news/NewsInteractor.java @@ -0,0 +1,70 @@ +package use_case.news; + +import entity.NewsArticle; +import java.util.List; + +/** + * Implement "what happens when user asks for news" + *

+ * 1. Validate the NewsRequestModel + * 2. Call NewsDataAccessInterface + * 3. Build a NewsResponse Model + * 4. Call the presenter -- NewsOutputBoundary + */ +public class NewsInteractor implements NewsInputBoundary{ + private final NewsDataAccessInterface newsDataAccess; + private final NewsOutputBoundary newsPresenter; + + public NewsInteractor(NewsDataAccessInterface newsDataAccess, + NewsOutputBoundary newsPresenter) { + this.newsDataAccess = newsDataAccess; + this.newsPresenter = newsPresenter; + } + + @Override + public void executeMarketNews(NewsRequestModel requestModel) { + try { + List articles = newsDataAccess.loadMarketNews(); + + if (articles == null || articles.isEmpty()) { + newsPresenter.prepareFailView("No market news available."); + return; + } + + NewsResponseModel response = new NewsResponseModel(articles); + newsPresenter.prepareSuccessView(response); + + } catch (Exception e) { + newsPresenter.prepareFailView("Unable to load market news: " + e.getMessage()); + } + } + + @Override + public void executeCompanyNews(NewsRequestModel requestModel) { + String symbol = requestModel.getSymbol(); + + if (symbol == null || symbol.isBlank()) { + newsPresenter.prepareFailView("Symbol is required for company news."); + return; + } + + try { + List articles = newsDataAccess.loadCompanyNews( + symbol, + requestModel.getFromDate(), + requestModel.getToDate() + ); + + if (articles == null || articles.isEmpty()) { + newsPresenter.prepareFailView("No news found for symbol: " + symbol); + return; + } + + NewsResponseModel response = new NewsResponseModel(articles); + newsPresenter.prepareSuccessView(response); + + } catch (Exception e) { + newsPresenter.prepareFailView("Unable to load company news: " + e.getMessage()); + } + } +} diff --git a/src/main/java/use_case/news/NewsOutputBoundary.java b/src/main/java/use_case/news/NewsOutputBoundary.java new file mode 100644 index 00000000..97023df9 --- /dev/null +++ b/src/main/java/use_case/news/NewsOutputBoundary.java @@ -0,0 +1,11 @@ +package use_case.news; + +/** + * Interface implemented by the presenter + */ +public interface NewsOutputBoundary { + + void prepareSuccessView(NewsResponseModel responseModel); + + void prepareFailView(String errorMessage); +} diff --git a/src/main/java/use_case/news/NewsRequestModel.java b/src/main/java/use_case/news/NewsRequestModel.java new file mode 100644 index 00000000..9e6e6226 --- /dev/null +++ b/src/main/java/use_case/news/NewsRequestModel.java @@ -0,0 +1,46 @@ +package use_case.news; + +/** + * Request model for news. + * For market news, symbol/fromDate/toDate can all be null. + * For company news, symbol must be non-null; dates may be null (use default range). + * Input Data holder, the data comes from the UI + */ +public class NewsRequestModel { + + private final String symbol; // e.g. "AAPL" or null for market news + private final String fromDate; // "YYYY-MM-DD" or null + private final String toDate; // "YYYY-MM-DD" or null + + // Constructor for general market news + public NewsRequestModel() { + this.symbol = null; + this.fromDate = null; + this.toDate = null; + } + + // Constructor for company news + public NewsRequestModel(String symbol, String fromDate, String toDate) { + this.symbol = symbol; + this.fromDate = fromDate; + this.toDate = toDate; + } + + public String getSymbol() { + return symbol; + } + + public String getFromDate() { + return fromDate; + } + + public String getToDate() { + return toDate; + } + + // Market news don't have a symbol + public boolean isMarketNewsRequest() { + return symbol == null || symbol.isBlank(); + } +} + diff --git a/src/main/java/use_case/news/NewsResponseModel.java b/src/main/java/use_case/news/NewsResponseModel.java new file mode 100644 index 00000000..5463c7c8 --- /dev/null +++ b/src/main/java/use_case/news/NewsResponseModel.java @@ -0,0 +1,21 @@ +package use_case.news; + +import entity.NewsArticle; +import java.util.List; + +/** + * Data holder for the presenter to build the View + * Created by the Interactor and passed to OutputBoundary + */ +public class NewsResponseModel { + + private final List articles; + + public NewsResponseModel(List articles) { + this.articles = articles; + } + + public List getArticles() { + return articles; + } +} diff --git a/src/main/java/use_case/stock_search/StockSearchDataAccessInterface.java b/src/main/java/use_case/stock_search/StockSearchDataAccessInterface.java new file mode 100644 index 00000000..451d0f9c --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchDataAccessInterface.java @@ -0,0 +1,7 @@ +package use_case.stock_search; + +import entity.StockQuote; + +public interface StockSearchDataAccessInterface { + StockQuote loadQuote(String symbol) throws Exception; +} \ No newline at end of file diff --git a/src/main/java/use_case/stock_search/StockSearchInputBoundary.java b/src/main/java/use_case/stock_search/StockSearchInputBoundary.java new file mode 100644 index 00000000..40456e43 --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchInputBoundary.java @@ -0,0 +1,5 @@ +package use_case.stock_search; + +public interface StockSearchInputBoundary { + void execute(StockSearchRequestModel requestModel); +} diff --git a/src/main/java/use_case/stock_search/StockSearchInteractor.java b/src/main/java/use_case/stock_search/StockSearchInteractor.java new file mode 100644 index 00000000..e7f253be --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchInteractor.java @@ -0,0 +1,48 @@ +package use_case.stock_search; + +import entity.StockQuote; + +public class StockSearchInteractor implements StockSearchInputBoundary { + + private final StockSearchDataAccessInterface dataAccess; + private final StockSearchOutputBoundary presenter; + + public StockSearchInteractor(StockSearchDataAccessInterface dataAccess, + StockSearchOutputBoundary presenter) { + this.dataAccess = dataAccess; + this.presenter = presenter; + } + + @Override + public void execute(StockSearchRequestModel requestModel) { + String symbol = requestModel.getSymbol(); + + if (symbol == null || symbol.trim().isEmpty()) { + presenter.prepareFailView("Please enter a symbol."); + return; + } + + symbol = symbol.trim().toUpperCase(); + + try { + StockQuote quote = dataAccess.loadQuote(symbol); + + StockSearchResponseModel responseModel = new StockSearchResponseModel( + quote.getSymbol(), + quote.getCompanyName(), + quote.getExchange(), + quote.getIndustry(), + quote.getMarketCap(), + quote.getCurrentPrice(), + quote.getOpen(), + quote.getHigh(), + quote.getLow(), + quote.getPreviousClose() + ); + presenter.prepareSuccessView(responseModel); + } + catch (Exception e) { + presenter.prepareFailView("Unable to load quote. " + e.getMessage()); + } + } +} diff --git a/src/main/java/use_case/stock_search/StockSearchOutputBoundary.java b/src/main/java/use_case/stock_search/StockSearchOutputBoundary.java new file mode 100644 index 00000000..22e14512 --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchOutputBoundary.java @@ -0,0 +1,6 @@ +package use_case.stock_search; + +public interface StockSearchOutputBoundary { + void prepareSuccessView(StockSearchResponseModel responseModel); + void prepareFailView(String errorMessage); +} diff --git a/src/main/java/use_case/stock_search/StockSearchRequestModel.java b/src/main/java/use_case/stock_search/StockSearchRequestModel.java new file mode 100644 index 00000000..b6fa6d27 --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchRequestModel.java @@ -0,0 +1,13 @@ +package use_case.stock_search; + +public class StockSearchRequestModel { + private final String symbol; + + public StockSearchRequestModel(String symbol) { + this.symbol = symbol; + } + + public String getSymbol() { + return symbol; + } +} diff --git a/src/main/java/use_case/stock_search/StockSearchResponseModel.java b/src/main/java/use_case/stock_search/StockSearchResponseModel.java new file mode 100644 index 00000000..e5b5792a --- /dev/null +++ b/src/main/java/use_case/stock_search/StockSearchResponseModel.java @@ -0,0 +1,76 @@ +package use_case.stock_search; + +public class StockSearchResponseModel { + private final String symbol; + private final String companyName; + private final String exchange; + private final String industry; + private final double marketCap; + private final double currentPrice; + private final double open; + private final double high; + private final double low; + private final double previousClose; + + public StockSearchResponseModel(String symbol, + String companyName, + String exchange, + String industry, + double marketCap, + double currentPrice, + double open, + double high, + double low, + double previousClose) { + this.symbol = symbol; + this.companyName = companyName; + this.exchange = exchange; + this.industry = industry; + this.marketCap = marketCap; + this.currentPrice = currentPrice; + this.open = open; + this.high = high; + this.low = low; + this.previousClose = previousClose; + } + + public String getSymbol() { + return symbol; + } + + public String getCompanyName() { + return companyName; + } + + public String getExchange() { + return exchange; + } + + public String getIndustry() { + return industry; + } + + public double getMarketCap() { + return marketCap; + } + + public double getCurrentPrice() { + return currentPrice; + } + + public double getOpen() { + return open; + } + + public double getHigh() { + return high; + } + + public double getLow() { + return low; + } + + public double getPreviousClose() { + return previousClose; + } +} diff --git a/src/main/java/use_case/trade/TradeDataAccessInterface.java b/src/main/java/use_case/trade/TradeDataAccessInterface.java new file mode 100644 index 00000000..93af0d5a --- /dev/null +++ b/src/main/java/use_case/trade/TradeDataAccessInterface.java @@ -0,0 +1,21 @@ +package use_case.trade; + +/** + * Data access interface for trade feed operations. + * Infrastructure implementations (e.g., Finnhub WebSocket) should implement this. + */ +public interface TradeDataAccessInterface { + + /** + * Connects to the trade feed and begins streaming trades for the given symbol. + * @param symbol The trading symbol to subscribe to (e.g., "BINANCE:BTCUSDT", "AAPL") + * @param listener The listener to receive trade updates + */ + void connect(String symbol, TradeListener listener); + + /** + * Gracefully disconnects from the trade feed. + */ + void disconnect(); +} + diff --git a/src/main/java/use_case/trade/TradeInputBoundary.java b/src/main/java/use_case/trade/TradeInputBoundary.java new file mode 100644 index 00000000..a850e47e --- /dev/null +++ b/src/main/java/use_case/trade/TradeInputBoundary.java @@ -0,0 +1,18 @@ +package use_case.trade; + +/** + * Input boundary for the trade use case. + */ +public interface TradeInputBoundary { + /** + * Executes the trade connection use case. + * @param requestModel The request containing the symbol to connect to + */ + void execute(TradeRequestModel requestModel); + + /** + * Executes the trade disconnection use case. + */ + void disconnect(); +} + diff --git a/src/main/java/use_case/trade/TradeInteractor.java b/src/main/java/use_case/trade/TradeInteractor.java new file mode 100644 index 00000000..7a3ab0b4 --- /dev/null +++ b/src/main/java/use_case/trade/TradeInteractor.java @@ -0,0 +1,62 @@ +package use_case.trade; + +import entity.Trade; + +/** + * Interactor for the trade use case. + * Implements the business logic for connecting to and receiving trade data. + */ +public class TradeInteractor implements TradeInputBoundary { + + private final TradeDataAccessInterface tradeDataAccess; + private final TradeOutputBoundary tradePresenter; + private final TradeListener internalListener; + + public TradeInteractor(TradeDataAccessInterface tradeDataAccess, + TradeOutputBoundary tradePresenter) { + this.tradeDataAccess = tradeDataAccess; + this.tradePresenter = tradePresenter; + + // Create internal listener that converts TradeListener callbacks to OutputBoundary calls + this.internalListener = new TradeListener() { + @Override + public void onTrade(Trade trade) { + TradeResponseModel responseModel = new TradeResponseModel(trade, null, false); + tradePresenter.prepareSuccessView(responseModel); + } + + @Override + public void onStatusChanged(String statusText, boolean isError) { + tradePresenter.prepareStatusView(statusText, isError); + } + }; + } + + @Override + public void execute(TradeRequestModel requestModel) { + String symbol = requestModel.getSymbol(); + + if (symbol == null || symbol.trim().isEmpty()) { + tradePresenter.prepareFailView("Please enter a symbol."); + return; + } + + symbol = symbol.trim(); + + try { + tradeDataAccess.connect(symbol, internalListener); + } catch (Exception e) { + tradePresenter.prepareFailView("Unable to connect to trade feed. " + e.getMessage()); + } + } + + @Override + public void disconnect() { + try { + tradeDataAccess.disconnect(); + } catch (Exception e) { + tradePresenter.prepareFailView("Unable to disconnect. " + e.getMessage()); + } + } +} + diff --git a/src/main/java/use_case/trade/TradeListener.java b/src/main/java/use_case/trade/TradeListener.java new file mode 100644 index 00000000..4174c8cd --- /dev/null +++ b/src/main/java/use_case/trade/TradeListener.java @@ -0,0 +1,21 @@ +package use_case.trade; + +import entity.Trade; + +/** + * Callback interface that presentation layer (e.g., Swing UI) implements + * to receive trade updates and connection status from a TradeFeed. + */ +public interface TradeListener { + + /** + * Called when a new trade is received from the data source. + */ + void onTrade(Trade trade); + + /** + * Called when the underlying connection status changes (connecting, connected, failed, etc.). + */ + void onStatusChanged(String statusText, boolean isError); +} + diff --git a/src/main/java/use_case/trade/TradeOutputBoundary.java b/src/main/java/use_case/trade/TradeOutputBoundary.java new file mode 100644 index 00000000..d53a7fd4 --- /dev/null +++ b/src/main/java/use_case/trade/TradeOutputBoundary.java @@ -0,0 +1,26 @@ +package use_case.trade; + +/** + * Output boundary for the trade use case. + */ +public interface TradeOutputBoundary { + /** + * Prepares the success view when a trade is received. + * @param responseModel The trade response model + */ + void prepareSuccessView(TradeResponseModel responseModel); + + /** + * Prepares the fail view when an error occurs. + * @param errorMessage The error message + */ + void prepareFailView(String errorMessage); + + /** + * Prepares the status update view. + * @param statusText The status text + * @param isError Whether this is an error status + */ + void prepareStatusView(String statusText, boolean isError); +} + diff --git a/src/main/java/use_case/trade/TradeRequestModel.java b/src/main/java/use_case/trade/TradeRequestModel.java new file mode 100644 index 00000000..fa4dc231 --- /dev/null +++ b/src/main/java/use_case/trade/TradeRequestModel.java @@ -0,0 +1,17 @@ +package use_case.trade; + +/** + * Request model for trade feed connection. + */ +public class TradeRequestModel { + private final String symbol; + + public TradeRequestModel(String symbol) { + this.symbol = symbol; + } + + public String getSymbol() { + return symbol; + } +} + diff --git a/src/main/java/use_case/trade/TradeResponseModel.java b/src/main/java/use_case/trade/TradeResponseModel.java new file mode 100644 index 00000000..f27d26c9 --- /dev/null +++ b/src/main/java/use_case/trade/TradeResponseModel.java @@ -0,0 +1,31 @@ +package use_case.trade; + +import entity.Trade; + +/** + * Response model for trade updates. + */ +public class TradeResponseModel { + private final Trade trade; + private final String statusText; + private final boolean isError; + + public TradeResponseModel(Trade trade, String statusText, boolean isError) { + this.trade = trade; + this.statusText = statusText; + this.isError = isError; + } + + public Trade getTrade() { + return trade; + } + + public String getStatusText() { + return statusText; + } + + public boolean isError() { + return isError; + } +} + diff --git a/src/main/java/use_case/watchlist/WatchlistInputBoundary.java b/src/main/java/use_case/watchlist/WatchlistInputBoundary.java new file mode 100644 index 00000000..1e452f5c --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistInputBoundary.java @@ -0,0 +1,8 @@ +package use_case.watchlist; + +public interface WatchlistInputBoundary { + /* + * Executes the Watchlist Use Case. + */ + void execute(WatchlistInputData watchlistInputData); +} diff --git a/src/main/java/use_case/watchlist/WatchlistInputData.java b/src/main/java/use_case/watchlist/WatchlistInputData.java new file mode 100644 index 00000000..36c5070a --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistInputData.java @@ -0,0 +1,19 @@ +package use_case.watchlist; + +import org.json.JSONObject; + +public class WatchlistInputData { + + private final String username; + private final JSONObject watchlistInputData; + + public WatchlistInputData(String username, JSONObject watchlistInputData) { + this.username = username; + this.watchlistInputData = watchlistInputData; + } + public JSONObject getWatchlistInputData() { + return watchlistInputData; + } + public String getUsername() { + return username; } +} diff --git a/src/main/java/use_case/watchlist/WatchlistInteractor.java b/src/main/java/use_case/watchlist/WatchlistInteractor.java new file mode 100644 index 00000000..197352b9 --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistInteractor.java @@ -0,0 +1,34 @@ +package use_case.watchlist; + + +import entity.User; + +public class WatchlistInteractor implements WatchlistInputBoundary { + private final WatchlistUserDataAccessInterface wlUserDataAccessObject; + private final WatchlistOutputBoundary watchlistPresenter; + + public WatchlistInteractor(WatchlistUserDataAccessInterface wlUserDataAccessInterface, + WatchlistOutputBoundary watchlistOutputBoundary) { + this.wlUserDataAccessObject = wlUserDataAccessInterface; + this.watchlistPresenter = watchlistOutputBoundary; + } + + public void execute(WatchlistInputData watchlistInputData) { + String username = watchlistInputData.getUsername(); + + if (!wlUserDataAccessObject.existsByName(username)) { + watchlistPresenter.prepareFailView("User does not exist."); + return; + } + User user = wlUserDataAccessObject.get(username); + + user.getWatchlist().add(watchlistInputData.getWatchlistInputData()); + + wlUserDataAccessObject.save(user); + + watchlistPresenter.prepareSuccessView( + new WatchlistOutputData(user.getWatchlist()) + ); + } + +} diff --git a/src/main/java/use_case/watchlist/WatchlistOutputBoundary.java b/src/main/java/use_case/watchlist/WatchlistOutputBoundary.java new file mode 100644 index 00000000..3708914a --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistOutputBoundary.java @@ -0,0 +1,8 @@ +package use_case.watchlist; + +public interface WatchlistOutputBoundary { + + void prepareSuccessView(WatchlistOutputData outputData); + + void prepareFailView(String errorMessage); +} diff --git a/src/main/java/use_case/watchlist/WatchlistOutputData.java b/src/main/java/use_case/watchlist/WatchlistOutputData.java new file mode 100644 index 00000000..ac38c497 --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistOutputData.java @@ -0,0 +1,15 @@ +package use_case.watchlist; + +import org.json.JSONObject; + +import java.util.ArrayList; + +public class WatchlistOutputData { + private final ArrayList watchlist; + public WatchlistOutputData(ArrayList watchlist) { + this.watchlist = watchlist; + } + public ArrayList getWatchlist() { + return watchlist; + } +} diff --git a/src/main/java/use_case/watchlist/WatchlistUserDataAccessInterface.java b/src/main/java/use_case/watchlist/WatchlistUserDataAccessInterface.java new file mode 100644 index 00000000..ad5a8e80 --- /dev/null +++ b/src/main/java/use_case/watchlist/WatchlistUserDataAccessInterface.java @@ -0,0 +1,11 @@ +package use_case.watchlist; + +import entity.User; + +public interface WatchlistUserDataAccessInterface { + User get(String username); + + void save(User user); + + boolean existsByName(String username); +} diff --git a/src/main/java/view/AccountView.java b/src/main/java/view/AccountView.java new file mode 100644 index 00000000..113c10ec --- /dev/null +++ b/src/main/java/view/AccountView.java @@ -0,0 +1,108 @@ +package view; + +import interface_adapter.ViewManagerModel; +import interface_adapter.account.AccountController; +import interface_adapter.account.AccountViewModel; +import org.json.JSONObject; + +import javax.swing.*; +import java.awt.*; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.ArrayList; + +public class AccountView extends JPanel implements PropertyChangeListener { + private AccountController accountController; + private ViewManagerModel viewManagerModel; + private String homeViewName; + private final String viewName = "account"; + private final AccountViewModel viewModel; + private final JPanel watchlistPanel; + private final JScrollPane scrollPane; + private final JLabel usernameLabel; + private final JButton backButton; + + public AccountView(AccountViewModel accountViewModel) { + this.viewModel = accountViewModel; + this.viewModel.addPropertyChangeListener(this); + + setLayout(new BorderLayout()); + + JPanel topPanel = new JPanel(new BorderLayout()); + backButton = new JButton("Back"); + topPanel.add(backButton, BorderLayout.WEST); + + backButton.addActionListener(e -> { + if (viewManagerModel != null && homeViewName != null) { + viewManagerModel.setState(homeViewName); + viewManagerModel.firePropertyChange(); + } + }); + + usernameLabel = new JLabel("Watchlist"); + usernameLabel.setHorizontalAlignment(SwingConstants.CENTER); + topPanel.add(usernameLabel, BorderLayout.CENTER); + + add(topPanel, BorderLayout.NORTH); + + watchlistPanel = new JPanel(); + watchlistPanel.setLayout(new BoxLayout(watchlistPanel, BoxLayout.Y_AXIS)); + scrollPane = new JScrollPane(watchlistPanel); + scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); + + add(scrollPane, BorderLayout.CENTER); + } + + public void propertyChange(PropertyChangeEvent evt) { + + watchlistPanel.removeAll(); + + ArrayList watchlist = viewModel.getState().getWatchlist(); + if (watchlist == null) { + watchlist = new ArrayList<>(); + } + + for (JSONObject item : watchlist) { + if (item == null) continue; + + String text = null; + if (item.has("stock")) { + text = item.getString("stock"); + } else if (item.has("info")) { + text = item.getString("info"); + } + + if (text != null && !text.isEmpty()) { + JPanel itemPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + itemPanel.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + + JLabel stockLabel = new JLabel(text); + itemPanel.add(stockLabel); + + watchlistPanel.add(itemPanel); + } + } + watchlistPanel.revalidate(); + watchlistPanel.repaint(); + } + + public String getViewName() { + return viewName; + } + + public void loadAccount(String username) { + if (accountController != null) { + accountController.loadWatchlist(username); + } + } + + public void setBackNavigation(ViewManagerModel viewManagerModel, String homeViewName) { + this.viewManagerModel = viewManagerModel; + this.homeViewName = homeViewName; + } + + public void setController(AccountController accountController) { + this.accountController = accountController; + } + +} diff --git a/src/main/java/view/EarningsHistoryView.java b/src/main/java/view/EarningsHistoryView.java new file mode 100644 index 00000000..8bed6df0 --- /dev/null +++ b/src/main/java/view/EarningsHistoryView.java @@ -0,0 +1,119 @@ +package view; + +import entity.EarningsRecord; +import interface_adapter.earnings_history.EarningsHistoryController; +import interface_adapter.earnings_history.EarningsHistoryState; +import interface_adapter.earnings_history.EarningsHistoryViewModel; +import interface_adapter.ViewManagerModel; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.List; + +public class EarningsHistoryView extends JPanel implements PropertyChangeListener { + + private final String viewName = "earnings history"; + private final EarningsHistoryController controller; + private final EarningsHistoryViewModel viewModel; + + private final JTextField symbolField = new JTextField("", 25); + private final JButton loadButton = new JButton("Load Earnings"); + private final JButton backButton = new JButton("Back"); + + private final DefaultTableModel tableModel = new DefaultTableModel( + new Object[]{"Period", "Actual", "Estimate", "Surprise"}, 0 + ); + private final JTable table = new JTable(tableModel); + + public EarningsHistoryView(EarningsHistoryController controller, + EarningsHistoryViewModel viewModel) { + this.controller = controller; + this.viewModel = viewModel; + + viewModel.addPropertyChangeListener(this); + + setLayout(new BorderLayout()); + setBorder(BorderFactory.createTitledBorder("Company Earnings")); + + JPanel topPanel = new JPanel(); + topPanel.add(new JLabel("Company Symbol:")); + topPanel.add(symbolField); + topPanel.add(loadButton); + topPanel.add(backButton); + add(topPanel, BorderLayout.NORTH); + + table.setFillsViewportHeight(true); + add(new JScrollPane(table), BorderLayout.CENTER); + + loadButton.addActionListener(e -> + controller.onLoadButtonClicked(symbolField.getText())); + + loadButton.setFont(new Font("Segoe UI", Font.BOLD, 12)); + } + + public String getViewName() { + return viewName; + } + + // AppBuilder calls this so the Back button knows where to go + public void setBackNavigation(ViewManagerModel viewManagerModel, + String loggedInViewName) { + backButton.addActionListener(e -> { + viewManagerModel.setState(loggedInViewName); + viewManagerModel.firePropertyChange(); + }); + } + + + @Override + public void propertyChange(PropertyChangeEvent evt) { + EarningsHistoryState state = (EarningsHistoryState) evt.getNewValue(); + + loadButton.setEnabled(!state.isLoading()); + updateTable(state.getRecords()); + showMessageIfAny(state); + } + + private void updateTable(List records) { + tableModel.setRowCount(0); + for (EarningsRecord r : records) { + tableModel.addRow(new Object[]{ + r.getPeriod(), + r.getActual() != null ? String.format("%.3f", r.getActual()) : "N/A", + r.getEstimate() != null ? String.format("%.3f", r.getEstimate()) : "N/A", + r.getSurprise() != null ? String.format("%.3f", r.getSurprise()) : "N/A" + }); + } + } + + private void showMessageIfAny(EarningsHistoryState state) { + String msg = state.getMessage(); + if (msg == null || msg.isEmpty()) return; + + int type; + String title; + switch (state.getMessageType()) { + case WARNING -> { + type = JOptionPane.WARNING_MESSAGE; + title = "No Data"; + } + case ERROR -> { + type = JOptionPane.ERROR_MESSAGE; + title = "Error"; + } + case INFO -> { + type = JOptionPane.INFORMATION_MESSAGE; + title = "Info"; + } + default -> { + type = JOptionPane.PLAIN_MESSAGE; + title = "Info"; + } + } + + JOptionPane.showMessageDialog(this, msg, title, type); + } +} diff --git a/src/main/java/view/FilterSearchView.java b/src/main/java/view/FilterSearchView.java new file mode 100644 index 00000000..0c2f67d0 --- /dev/null +++ b/src/main/java/view/FilterSearchView.java @@ -0,0 +1,199 @@ +package view; + +import data_access.FilterSearchDataAccessObject; +import entity.Stock; +import interface_adapter.ViewManagerModel; +import interface_adapter.filter_search.FilterSearchController; +import interface_adapter.filter_search.FilterSearchState; +import interface_adapter.filter_search.FilterSearchViewModel; +import use_case.filter_search.FilterSearchInputBoundary; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.ArrayList; +import java.util.List; + +/** + * The View for when the user is in the Filter Search Page. + */ + +public class FilterSearchView extends JPanel implements PropertyChangeListener { + private FilterSearchController filterSearchController; + private final FilterSearchViewModel filterSearchViewModel; + + private ViewManagerModel viewManagerModel; + private String homeViewName; + + // Table-related fields + private JTable table; + private DefaultTableModel tableModel; + + private static final int COL_SYMBOL = 0; + private static final int COL_DESCRIPTION = 1; + private static final int COL_CURRENCY = 2; + private static final int COL_DISPLAY = 3; + private static final int COL_FIGI = 4; + private static final int COL_MIC = 5; + private static final int COL_TYPE = 6; + + + + public FilterSearchView(FilterSearchViewModel filterSearchViewModel) { + this.filterSearchViewModel = filterSearchViewModel; + this.filterSearchViewModel.addPropertyChangeListener(this); + + final String[] exchangeOptions = FilterSearchInputBoundary.EXCHANGE_OPTIONS; + final String[] micOptions = FilterSearchInputBoundary.MIC_OPTIONS; + final String[] securityOptions = FilterSearchInputBoundary.SECURITY_OPTIONS; + final String[] currencyOptions = FilterSearchInputBoundary.CURRENCY_OPTIONS; + + final JComboBox exchangeDrop = new JComboBox<>(exchangeOptions); + final JComboBox micDrop = new JComboBox<>(micOptions); + final JComboBox securityDrop = new JComboBox<>(securityOptions); + final JComboBox currencyDrop = new JComboBox<>(currencyOptions); + JButton search = new JButton("Search"); + + setLayout(new BorderLayout()); + + JPanel topPanel = new JPanel(new BorderLayout()); + this.add(topPanel, BorderLayout.NORTH); + + JPanel leftPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton backToHomeButton = new JButton("Back"); + leftPanel.add(backToHomeButton); + topPanel.add(leftPanel, BorderLayout.WEST); + + JPanel filtersPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + + JPanel panel_e = new JPanel(); + panel_e.add(new JLabel("Exchange")); + panel_e.add(exchangeDrop); + + JPanel panel_m = new JPanel(); + panel_m.add(new JLabel("MIC:")); + panel_m.add(micDrop); + + JPanel panel_s = new JPanel(); + panel_s.add(new JLabel("Security Type:")); + panel_s.add(securityDrop); + + JPanel panel_c = new JPanel(); + panel_c.add(new JLabel("Currency:")); + panel_c.add(currencyDrop); + + filtersPanel.add(panel_e); + filtersPanel.add(panel_m); + filtersPanel.add(panel_s); + filtersPanel.add(panel_c); + filtersPanel.add(search); + + topPanel.add(filtersPanel, BorderLayout.CENTER); + + String[] columnNames = {"MIC", "Description", "Currency", + "Display Symbol", "FIGI", "Symbol", "Security Type"}; + + tableModel = new DefaultTableModel(columnNames, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; // read-only table + } + }; + table = new JTable(tableModel); + table.setFillsViewportHeight(true); + table.setPreferredScrollableViewportSize(new Dimension(800, 400)); + + JScrollPane scrollPane = new JScrollPane(table); + add(scrollPane, BorderLayout.CENTER); + + // Home Button + backToHomeButton.addActionListener(e -> { + if (viewManagerModel != null && homeViewName != null) { + viewManagerModel.setState(homeViewName); + viewManagerModel.firePropertyChange(); + } + }); + + // Search Button + search.addActionListener(e -> { + + String ex = exchangeDrop.getSelectedItem() != null + ? exchangeDrop.getSelectedItem().toString() : ""; + String mi = micDrop.getSelectedItem() != null + ? micDrop.getSelectedItem().toString() : ""; + String sec = securityDrop.getSelectedItem() != null + ? securityDrop.getSelectedItem().toString() : ""; + String curr = currencyDrop.getSelectedItem() != null + ? currencyDrop.getSelectedItem().toString() : ""; + + if (filterSearchController != null) { + filterSearchController.loadStocks(ex, mi, sec, curr); + } else { + JOptionPane.showMessageDialog( + this, + "FilterSearchController is not set.", + "Warning", + JOptionPane.WARNING_MESSAGE + ); + } + }); + } + + public String getViewName() {; + return "Filter Search"; + } + + public void setFilterSearchController(FilterSearchController filterSearchController) { + this.filterSearchController = filterSearchController; + } + + + @Override + public void propertyChange(PropertyChangeEvent evt) { + List stocks = filterSearchViewModel.getStocks(); + System.out.println("VIEW propertyChange: stocks = " + + (stocks == null ? "null" : stocks.size())); + + String error = filterSearchViewModel.getErrorMessage(); + + if (error != null && !error.isEmpty()) { + JOptionPane.showMessageDialog( + this, + error, + "Search Error", + JOptionPane.ERROR_MESSAGE + ); + return; + } + + // Update table model with new stocks + tableModel.setRowCount(0); // clear old rows + + if (stocks != null) { + for (Stock s : stocks) { + Object[] row = new Object[7]; + row[COL_SYMBOL] = s.getSymbol(); + row[COL_DESCRIPTION] = s.getDescription(); + row[COL_CURRENCY] = s.getCurrency(); + row[COL_DISPLAY] = s.getDisplaySymbol(); + row[COL_FIGI] = s.getFigi(); + row[COL_MIC] = s.getMic(); + row[COL_TYPE] = s.getType(); + tableModel.addRow(row); + } + } + System.out.println("TABLE rowCount = " + tableModel.getRowCount()); + } + public void setBackNavigation(ViewManagerModel viewManagerModel, String homeViewName) { + this.viewManagerModel = viewManagerModel; + this.homeViewName = homeViewName; + } +} + + + + diff --git a/src/main/java/view/LoggedInView.java b/src/main/java/view/LoggedInView.java index 1eb2cbd8..442e1060 100644 --- a/src/main/java/view/LoggedInView.java +++ b/src/main/java/view/LoggedInView.java @@ -1,9 +1,16 @@ package view; +import interface_adapter.account.AccountController; import interface_adapter.logged_in.ChangePasswordController; import interface_adapter.logged_in.LoggedInState; import interface_adapter.logged_in.LoggedInViewModel; +import interface_adapter.stock_search.StockSearchController; +import interface_adapter.stock_search.StockSearchViewModel; import interface_adapter.logout.LogoutController; +import interface_adapter.news.NewsController; +import interface_adapter.ViewManagerModel; +import interface_adapter.market_status.MarketStatusViewModel; +import org.json.JSONObject; import javax.swing.*; import javax.swing.event.DocumentEvent; @@ -24,6 +31,19 @@ public class LoggedInView extends JPanel implements ActionListener, PropertyChan private final JLabel passwordErrorField = new JLabel(); private ChangePasswordController changePasswordController = null; private LogoutController logoutController; + private NewsController newsController; + private AccountController accountController; + private ViewManagerModel viewManagerModel; + private String newsViewName; + private String filterSearchViewName; + private String historyViewName; + private String accountViewName; + private String realtimeTradeViewName; + private JLabel marketStatusLabel; + private MarketStatusViewModel marketStatusViewModel; + + private StockSearchController stockSearchController; + private StockSearchViewModel stockSearchViewModel; private final JLabel username; @@ -32,29 +52,127 @@ public class LoggedInView extends JPanel implements ActionListener, PropertyChan private final JTextField passwordInputField = new JTextField(15); private final JButton changePassword; + private final JTextField searchStockField = new JTextField(25); + private final JButton searchButton = new JButton("Search"); + private final JTextArea stockInfoArea = new JTextArea(); + private final JButton addToWatchlistButton = new JButton("Add to Watchlist"); + public LoggedInView(LoggedInViewModel loggedInViewModel) { this.loggedInViewModel = loggedInViewModel; this.loggedInViewModel.addPropertyChangeListener(this); - final JLabel title = new JLabel("Logged In Screen"); - title.setAlignmentX(Component.CENTER_ALIGNMENT); - - final LabelTextPanel passwordInfo = new LabelTextPanel( - new JLabel("Password"), passwordInputField); - - final JLabel usernameInfo = new JLabel("Currently logged in: "); username = new JLabel(); - final JPanel buttons = new JPanel(); logOut = new JButton("Log Out"); - buttons.add(logOut); - changePassword = new JButton("Change Password"); - buttons.add(changePassword); logOut.addActionListener(this); - this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + this.setLayout(new BorderLayout()); + + final JPanel topToolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 15, 10)); + + final JButton newsButton = new JButton("News"); + newsButton.addActionListener(e -> { + System.out.println("News button clicked"); // debug + if (viewManagerModel != null && newsViewName != null) { + viewManagerModel.setState(newsViewName); + viewManagerModel.firePropertyChange(); + } + }); + + final JButton filterSearchButton = new JButton("Filter Search"); + filterSearchButton.addActionListener(e -> { + System.out.println("Filter Search button clicked"); // debug + if (viewManagerModel != null && newsViewName != null) { + viewManagerModel.setState(filterSearchViewName); + viewManagerModel.firePropertyChange(); + } + }); + + + final JButton historyButton = new JButton("History"); + historyButton.addActionListener(e -> { + if (viewManagerModel != null && historyViewName != null) { + viewManagerModel.setState(historyViewName); + viewManagerModel.firePropertyChange(); + } + }); + + final JButton realtimeTradeButton = new JButton("Realtime Trade"); + realtimeTradeButton.addActionListener(e -> { + if (viewManagerModel != null && realtimeTradeViewName != null) { + viewManagerModel.setState(realtimeTradeViewName); + viewManagerModel.firePropertyChange(); + } + }); + + final JButton accountButton = new JButton("Account"); + accountButton.addActionListener(e -> { + System.out.println("Account button clicked"); + if (viewManagerModel != null && accountViewName != null) { + viewManagerModel.setState(accountViewName); + viewManagerModel.firePropertyChange(); + } + }); + + topToolbar.add(newsButton); + topToolbar.add(filterSearchButton); + topToolbar.add(historyButton); + topToolbar.add(realtimeTradeButton); + topToolbar.add(accountButton); + + marketStatusLabel = new JLabel("Loading market status..."); + marketStatusLabel.setFont(marketStatusLabel.getFont().deriveFont(Font.PLAIN, 11f)); + marketStatusLabel.setForeground(Color.DARK_GRAY); + + topToolbar.add(Box.createHorizontalStrut(20)); + topToolbar.add(marketStatusLabel); + this.add(topToolbar, BorderLayout.NORTH); + + final JPanel centerPanel = new JPanel(); + centerPanel.setLayout(new BoxLayout(centerPanel, BoxLayout.Y_AXIS)); + + final JPanel searchPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 10)); + final JLabel searchLabel = new JLabel("Search Stock:"); + searchPanel.add(searchLabel); + searchPanel.add(searchStockField); + searchPanel.add(searchButton); + + centerPanel.add(searchPanel); + + stockInfoArea.setLineWrap(true); + stockInfoArea.setWrapStyleWord(true); + + final JPanel stockInfoPanel = new JPanel(new BorderLayout()); + stockInfoPanel.setBorder(BorderFactory.createTitledBorder("Stock Information")); + stockInfoPanel.add(new JScrollPane(stockInfoArea), BorderLayout.CENTER); + + centerPanel.add(stockInfoPanel); + + this.add(centerPanel, BorderLayout.CENTER); + + searchButton.addActionListener(e -> { + if (stockSearchController != null) { + stockSearchController.search(searchStockField.getText()); + } + }); + + final JPanel bottomPanel = new JPanel(new BorderLayout()); + bottomPanel.add(addToWatchlistButton, BorderLayout.CENTER); + + addToWatchlistButton.addActionListener(e -> { + String username = loggedInViewModel.getState().getUsername(); + System.out.println("watchlist button clicked"); + if (accountController != null) { + JSONObject newItem = new JSONObject(); +// newItem.put("stock", stockSymbol); + newItem.put("info", stockInfoArea.getText()); + accountController.addToWatchlist(username, newItem); + } + }); + + this.add(bottomPanel, BorderLayout.SOUTH); passwordInputField.getDocument().addDocumentListener(new DocumentListener() { @@ -64,6 +182,8 @@ private void documentListenerHelper() { loggedInViewModel.setState(currentState); } + + @Override public void insertUpdate(DocumentEvent e) { documentListenerHelper(); @@ -94,13 +214,6 @@ public void changedUpdate(DocumentEvent e) { } ); - this.add(title); - this.add(usernameInfo); - this.add(username); - - this.add(passwordInfo); - this.add(passwordErrorField); - this.add(buttons); } /** @@ -139,7 +252,95 @@ public void setChangePasswordController(ChangePasswordController changePasswordC this.changePasswordController = changePasswordController; } + public void setStockSearchController(StockSearchController stockSearchController) { + this.stockSearchController = stockSearchController; + } + + public void setStockSearchViewModel(StockSearchViewModel stockSearchViewModel) { + this.stockSearchViewModel = stockSearchViewModel; + this.stockSearchViewModel.addPropertyChangeListener(evt -> { + if (!"state".equals(evt.getPropertyName())) { + return; + } + StockSearchViewModel vm = (StockSearchViewModel) evt.getNewValue(); + if (vm.getErrorMessage() != null && !vm.getErrorMessage().isBlank()) { + stockInfoArea.setText(vm.getErrorMessage()); + } else if (vm.getInfoText() != null) { + stockInfoArea.setText(vm.getInfoText()); + } else { + stockInfoArea.setText(""); + } + }); + } + + public void setAccountController(AccountController accountController) { + this.accountController = accountController; + } + public void setLogoutController(LogoutController logoutController) { // TODO: save the logout controller in the instance variable. } + + public void setNewsNavigation(ViewManagerModel viewManagerModel, String newsViewName) { + this.viewManagerModel = viewManagerModel; + this.newsViewName = newsViewName; + } + + public void setFilterSearchNavigation(ViewManagerModel viewManagerModel, String filterSearchViewName) { + this.viewManagerModel = viewManagerModel; + this.filterSearchViewName = filterSearchViewName; + } + + public void setHistoryNavigation(ViewManagerModel viewManagerModel, + String historyViewName) { + this.viewManagerModel = viewManagerModel; + this.historyViewName = historyViewName; + } + + + + public void setAccountNavigation(ViewManagerModel viewManagerModel, + String accountViewName) { + this.viewManagerModel = viewManagerModel; + this.accountViewName = accountViewName; + } + + public void setRealtimeTradeNavigation(ViewManagerModel viewManagerModel, + String tradeViewName) { + this.viewManagerModel = viewManagerModel; + this.realtimeTradeViewName = tradeViewName; + } + + public void setMarketStatusViewModel(MarketStatusViewModel viewModel) { + this.marketStatusViewModel = viewModel; + + // Listen for changes in market status + this.marketStatusViewModel.addPropertyChangeListener(evt -> { + if (!"state".equals(evt.getPropertyName())) return; + updateMarketStatusLabel(); + }); + } + + private void updateMarketStatusLabel() { + if (marketStatusViewModel == null) { + return; + } + + String text = marketStatusViewModel.getStatusText(); + if (text == null || text.isBlank()) { + marketStatusLabel.setText("Market status unavailable"); + marketStatusLabel.setForeground(Color.GRAY); + return; + } + + marketStatusLabel.setText(text); + + if (marketStatusViewModel.getErrorMessage() != null) { + marketStatusLabel.setForeground(Color.RED); + } else if (marketStatusViewModel.isOpen()) { + marketStatusLabel.setForeground(new Color(0, 128, 0)); // green-ish + } else { + marketStatusLabel.setForeground(Color.GRAY); + } + } } diff --git a/src/main/java/view/NewsView.java b/src/main/java/view/NewsView.java new file mode 100644 index 00000000..7684b8f8 --- /dev/null +++ b/src/main/java/view/NewsView.java @@ -0,0 +1,283 @@ +package view; + +import interface_adapter.news.NewsController; +import interface_adapter.news.NewsViewModel; +import interface_adapter.ViewManagerModel; +import entity.NewsArticle; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import javax.swing.JTextArea; + + +public class NewsView extends JPanel implements PropertyChangeListener { + + private ViewManagerModel viewManagerModel; + private String homeViewName; + + private final String viewName = "news"; + + private final NewsController controller; + private final NewsViewModel viewModel; + + private final JButton backToHomeButton; + private final JButton marketNewsButton; + private final JButton companyNewsButton; + private final JTextField symbolField; + private final JTextField fromDateField; + private final JTextField toDateField; + private final JTable newsTable; + private final DefaultTableModel tableModel; + private final JLabel errorLabel; + + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + // column indices for later use + private static final int COL_DATE = 0; + private static final int COL_SYMBOL = 1; + private static final int COL_TITLE = 2; + private static final int COL_SUMMARY = 3; + private static final int COL_SOURCE = 4; + private static final int COL_LINK = 5; + private static final int COL_IMAGE = 6; + + public NewsView(NewsController controller, NewsViewModel viewModel) { + this.controller = controller; + this.viewModel = viewModel; + + this.viewModel.addPropertyChangeListener(this); + + setLayout(new BorderLayout()); + + // ===== TOP PANEL ===== + JPanel topPanel = new JPanel(new BorderLayout()); + + // Left: Back to Home button + JPanel leftPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + backToHomeButton = new JButton("Back to Home"); + leftPanel.add(backToHomeButton); + topPanel.add(leftPanel, BorderLayout.WEST); + + // Right: symbol/date filters and news buttons + JPanel rightPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + + symbolField = new JTextField(8); + fromDateField = new JTextField(10); + toDateField = new JTextField(10); + companyNewsButton = new JButton("Company News"); + marketNewsButton = new JButton("Market News"); + + rightPanel.add(new JLabel("Symbol:")); + rightPanel.add(symbolField); + rightPanel.add(new JLabel("From (YYYY-MM-DD):")); + rightPanel.add(fromDateField); + rightPanel.add(new JLabel("To (YYYY-MM-DD):")); + rightPanel.add(toDateField); + rightPanel.add(companyNewsButton); + rightPanel.add(marketNewsButton); + + topPanel.add(rightPanel, BorderLayout.EAST); + + add(topPanel, BorderLayout.NORTH); + + // ===== CENTRE TABLE ===== + String[] columnNames = {"Date", "Symbol", "Title", "Summary", "Source", "Link", "Image"}; + tableModel = new DefaultTableModel(columnNames, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + + // Create the NewsTable right here + newsTable = new JTable(tableModel); + + // set renderer for headline column to enable wrapping + newsTable.getColumnModel() + .getColumn(COL_SUMMARY) + .setCellRenderer(new TextAreaRenderer()); + + newsTable.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + int row = newsTable.rowAtPoint(e.getPoint()); + int col = newsTable.columnAtPoint(e.getPoint()); + + if (row < 0 || currentArticles == null || row >= currentArticles.size()) { + return; + } + + NewsArticle article = currentArticles.get(row); + + try { + if (col == COL_LINK) { // column index for "Link" + String url = article.getUrl(); + if (url != null && !url.isBlank()) { + java.awt.Desktop.getDesktop().browse(new java.net.URI(url)); + } + } else if (col == COL_IMAGE) { // column index for "Image" + String imgUrl = article.getImage(); + if (imgUrl != null && !imgUrl.isBlank()) { + java.awt.Desktop.getDesktop().browse(new java.net.URI(imgUrl)); + } + } + } catch (Exception ex) { + JOptionPane.showMessageDialog( + NewsView.this, + "Unable to open link: " + ex.getMessage(), + "Error", + JOptionPane.ERROR_MESSAGE + ); + } + } + }); + + // turn off auto-resize of chart + newsTable.setAutoResizeMode(JTable.AUTO_RESIZE_OFF); + + //set preferred width + newsTable.getColumnModel().getColumn(COL_DATE).setPreferredWidth(130); + newsTable.getColumnModel().getColumn(COL_SYMBOL).setPreferredWidth(80); + newsTable.getColumnModel().getColumn(COL_TITLE).setPreferredWidth(160); + newsTable.getColumnModel().getColumn(COL_SUMMARY).setPreferredWidth(360); + newsTable.getColumnModel().getColumn(COL_SOURCE).setPreferredWidth(100); + newsTable.getColumnModel().getColumn(COL_LINK).setPreferredWidth(60); + newsTable.getColumnModel().getColumn(COL_IMAGE).setPreferredWidth(60); + + JScrollPane scrollPane = new JScrollPane(newsTable); + add(scrollPane, BorderLayout.CENTER); + + // ===== BOTTOM ERROR LABEL ============================================== + errorLabel = new JLabel(" "); + errorLabel.setForeground(Color.RED); + add(errorLabel, BorderLayout.SOUTH); + + // ===== BUTTON ACTIONS ================================================== + + // Back to Home – placeholder for now + backToHomeButton.addActionListener(e -> { + if (viewManagerModel != null && homeViewName != null) { + viewManagerModel.setState(homeViewName); + viewManagerModel.firePropertyChange(); + } + }); + + // Market news – load top market news into centre table + marketNewsButton.addActionListener(e -> controller.loadMarketNews()); + + // Company news – use filters and show results in centre table + companyNewsButton.addActionListener(e -> { + String symbol = symbolField.getText().trim(); + String from = fromDateField.getText().trim(); + String to = toDateField.getText().trim(); + controller.loadCompanyNews(symbol, from, to); + }); + + // automatically show top market news when this view is created + controller.loadMarketNews(); + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + if (!"state".equals(evt.getPropertyName())) { + return; + } + + updateTable(viewModel.getArticles()); + updateError(viewModel.getErrorMessage()); + } + + private java.util.List currentArticles; + + private void updateTable(List articles) { + tableModel.setRowCount(0); // clear + currentArticles = articles; + + if (articles == null) { + return; + } + + for (NewsArticle article : articles) { + String dateStr = formatDate(article.getDatetime()); + String symbol = article.getRelated(); + String title = article.getHeadline(); + String summary = article.getSummary(); + String source = article.getSource(); + + String linkLabel = (article.getUrl() == null || article.getUrl().isBlank()) ? "" : "Open"; + String imageLabel = (article.getImage() == null || article.getImage().isBlank()) ? "" : "View"; + + tableModel.addRow(new Object[]{dateStr, symbol, title, summary, source, linkLabel, imageLabel}); + } + } + + private void updateError(String errorMessage) { + if (errorMessage == null || errorMessage.isBlank()) { + errorLabel.setText(" "); + } else { + errorLabel.setText(errorMessage); + } + } + + private String formatDate(long unixSeconds) { + if (unixSeconds <= 0) { + return ""; + } + LocalDateTime dt = LocalDateTime.ofInstant( + Instant.ofEpochSecond(unixSeconds), + ZoneId.systemDefault() + ); + return dt.format(DATE_FORMATTER); + } + + private static class TextAreaRenderer extends JTextArea implements javax.swing.table.TableCellRenderer { + + public TextAreaRenderer() { + setLineWrap(true); + setWrapStyleWord(true); + setOpaque(true); + } + + @Override + public java.awt.Component getTableCellRendererComponent( + JTable table, Object value, boolean isSelected, boolean hasFocus, + int row, int column) { + + setText(value == null ? "" : value.toString()); + + if (isSelected) { + setBackground(table.getSelectionBackground()); + setForeground(table.getSelectionForeground()); + } else { + setBackground(table.getBackground()); + setForeground(table.getForeground()); + } + + setSize(table.getColumnModel().getColumn(column).getWidth(), Short.MAX_VALUE); + int preferredHeight = getPreferredSize().height; + if (table.getRowHeight(row) != preferredHeight) { + table.setRowHeight(row, preferredHeight); + } + + return this; + } + } + + public String getViewName(){ + return this.viewName; + } + + public void setBackNavigation(ViewManagerModel viewManagerModel, String homeViewName) { + this.viewManagerModel = viewManagerModel; + this.homeViewName = homeViewName; + } +} diff --git a/src/main/java/view/TradeView.java b/src/main/java/view/TradeView.java new file mode 100644 index 00000000..b830a549 --- /dev/null +++ b/src/main/java/view/TradeView.java @@ -0,0 +1,559 @@ +package view; + +import entity.Trade; +import interface_adapter.ViewManagerModel; +import interface_adapter.market_status.MarketStatusViewModel; +import interface_adapter.trade.TradeController; +import interface_adapter.trade.TradeState; +import interface_adapter.trade.TradeViewModel; + +import javax.swing.*; +import java.awt.*; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Swing view for displaying real-time trade data. + * Follows Clean Architecture pattern using Controller and ViewModel. + */ +public class TradeView extends JPanel implements PropertyChangeListener { + + private final String viewName = "trade"; + private ViewManagerModel viewManagerModel; + private String homeViewName; + + private final TradeController tradeController; + private final TradeViewModel tradeViewModel; + private MarketStatusViewModel marketStatusViewModel; + + // Trade data labels + private final JLabel symbolLabel = new JLabel("---"); + private final JLabel priceLabel = new JLabel("---"); + private final JLabel volumeLabel = new JLabel("---"); + private final JLabel timestampLabel = new JLabel("---"); + private final JLabel statusLabel = new JLabel("Status: Disconnected", SwingConstants.CENTER); + private final JTextField symbolInputField = new JTextField(20); + private JButton connectButton; + private JButton disconnectButton; + private String currentSymbol = ""; + private long connectionStartTime = 0; + private long lastConnectionAttemptTime = 0; + private boolean isConnected = false; + private static final long SYMBOL_NOT_FOUND_TIMEOUT = 10000; // 10 seconds + private static final long CONNECTION_COOLDOWN_MS = 5000; // 5 seconds + private static final int DISCONNECT_DELAY_MS = 300; + private static final int ONE_SECOND_MS = 1000; + private static final int MILLISECONDS_PER_SECOND = 1000; + private static final String STATUS_CONNECTED = "Connected"; + private static final String STATUS_CONNECTING = "Connecting"; + private static final String STATUS_DISCONNECTED = "Disconnected"; + private static final String CRYPTO_PAIR_EXAMPLE = "BINANCE:BTCUSDT"; + + /** + * Initializes the GUI components. + */ + public TradeView(TradeController tradeController, TradeViewModel tradeViewModel) { + this.tradeController = tradeController; + this.tradeViewModel = tradeViewModel; + + tradeViewModel.addPropertyChangeListener(this); + + setLayout(new BorderLayout()); + + JPanel topPanel = new JPanel(new BorderLayout()); + + JPanel backPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton backButton = new JButton("Back to Home"); + backButton.addActionListener(e -> { + if (viewManagerModel != null && homeViewName != null) { + viewManagerModel.setState(homeViewName); + viewManagerModel.firePropertyChange(); + } + }); + backPanel.add(backButton); + topPanel.add(backPanel, BorderLayout.WEST); + + statusLabel.setFont(new Font("Arial", Font.BOLD, 14)); + statusLabel.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8)); + topPanel.add(statusLabel, BorderLayout.CENTER); + + add(topPanel, BorderLayout.NORTH); + + // --- Center: Trade data display --- + JPanel mainPanel = createMainDashboardCard(); + mainPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + add(mainPanel, BorderLayout.CENTER); + } + + /** + * Creates the main dashboard card that shows the trade data and connect button. + */ + private JPanel createMainDashboardCard() { + JPanel root = new JPanel(new BorderLayout(10, 10)); + + // Search panel at top with Connect and Disconnect buttons + JPanel searchPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 10)); + searchPanel.add(new JLabel("Symbol:")); + symbolInputField.setText(CRYPTO_PAIR_EXAMPLE); + symbolInputField.setToolTipText("Enter crypto pair (e.g., " + + CRYPTO_PAIR_EXAMPLE + ") or stock symbol (e.g., AAPL)"); + searchPanel.add(symbolInputField); + connectButton = new JButton("Connect"); + connectButton.addActionListener(e -> onConnectClicked()); + searchPanel.add(connectButton); + disconnectButton = new JButton("Disconnect"); + disconnectButton.addActionListener(e -> onDisconnectClicked()); + disconnectButton.setEnabled(false); // Initially disabled + searchPanel.add(disconnectButton); + root.add(searchPanel, BorderLayout.NORTH); + + // Data display panel + JPanel dataPanel = new JPanel(new GridLayout(4, 2, 10, 10)); + dataPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + styleLabel(symbolLabel); + styleLabel(priceLabel); + styleLabel(volumeLabel); + styleLabel(timestampLabel); + + dataPanel.add(new JLabel("Symbol:")); + dataPanel.add(symbolLabel); + + dataPanel.add(new JLabel("Last Price (P):")); + dataPanel.add(priceLabel); + + dataPanel.add(new JLabel("Volume (V):")); + dataPanel.add(volumeLabel); + + dataPanel.add(new JLabel("Timestamp (T):")); + dataPanel.add(timestampLabel); + + root.add(dataPanel, BorderLayout.CENTER); + + return root; + } + + /** + * Helper to apply basic styling to the data display labels. + */ + private void styleLabel(JLabel label) { + label.setFont(new Font("Monospaced", Font.BOLD, 18)); + label.setForeground(new Color(0, 128, 0)); // Green color for data + } + + /** + * Determines if a symbol is a cryptocurrency pair or a stock symbol. + * @param symbol The symbol to check + * @return "Crypto" if it's a crypto pair, "Stock" if it's a stock, "Unknown" if unclear + */ + private String detectSymbolType(String symbol) { + if (symbol == null || symbol.trim().isEmpty()) { + return "Unknown"; + } + + String upperSymbol = symbol.trim().toUpperCase(); + + // Check for crypto pair format: EXCHANGE:PAIR (e.g., BINANCE:BTCUSDT, COINBASE:BTCUSD) + if (upperSymbol.contains(":")) { + String[] parts = upperSymbol.split(":", 2); + if (parts.length == 2) { + String exchange = parts[0]; + String pair = parts[1]; + + // Common crypto exchanges + if (exchange.equals("BINANCE") || exchange.equals("COINBASE") + || exchange.equals("KRAKEN") || exchange.equals("BITSTAMP") + || exchange.equals("BITFINEX") || exchange.equals("GEMINI")) { + // Check if pair looks like a crypto pair + // (usually ends with USD, USDT, EUR, etc.) + if (pair.endsWith("USD") || pair.endsWith("USDT") + || pair.endsWith("EUR") || pair.endsWith("BTC") + || pair.endsWith("ETH") || pair.length() >= 6) { + return "Crypto"; + } + } + } + } + + // Stock symbols are typically 1-5 uppercase letters/numbers, no special characters + // Common patterns: AAPL, TSLA, GOOGL, BRK.B, 005930 (Korean stocks) + if (upperSymbol.matches("^[A-Z0-9]{1,5}$") || upperSymbol.matches("^[A-Z0-9]+\\.[A-Z]$")) { + return "Stock"; + } + + return "Unknown"; + } + + /** + * Handles the Connect button click by delegating to the Controller. + */ + private void onConnectClicked() { + String symbol = symbolInputField.getText().trim(); + if (symbol.isEmpty()) { + statusLabel.setText("Status: Please enter a symbol"); + statusLabel.setForeground(Color.RED); + return; + } + + // Detect symbol type + String symbolType = detectSymbolType(symbol); + + // If it's a stock symbol, check if market is closed + if (symbolType.equals("Stock")) { + if (marketStatusViewModel != null && !marketStatusViewModel.isOpen()) { + String marketStatusText = marketStatusViewModel.getStatusText(); + if (marketStatusText == null) { + marketStatusText = "Market is closed"; + } + String message = "Cannot connect to stock symbol '" + + symbol + "' because the market is closed.\n\n" + + "Market Status: " + marketStatusText + "\n\n" + + "Please try:\n" + + "• Wait until the market opens\n" + + "• Use a cryptocurrency pair instead (e.g., " + + CRYPTO_PAIR_EXAMPLE + ")"; + JOptionPane.showMessageDialog( + this, + message, + "Market Closed", + JOptionPane.WARNING_MESSAGE); + return; + } + + // Warn user if they're using a stock symbol (not crypto) + String warningMessage = "Stock symbols (like AAPL, TSLA) " + + "may not have real-time trade data available.\n\n" + + "Do you want to continue with '" + symbol + "'?"; + int result = JOptionPane.showConfirmDialog( + this, + warningMessage, + "Symbol Warning", + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE); + if (result == JOptionPane.NO_OPTION) { + return; + } + } + + // Check rate limiting - prevent too frequent connection attempts + long currentTime = System.currentTimeMillis(); + long timeSinceLastAttempt = currentTime - lastConnectionAttemptTime; + + if (timeSinceLastAttempt < CONNECTION_COOLDOWN_MS) { + long remainingTime = (CONNECTION_COOLDOWN_MS - timeSinceLastAttempt) + / MILLISECONDS_PER_SECOND; + String rateLimitMessage = "Please wait " + remainingTime + + " second(s) before connecting again.\n" + + "Too many requests may result in rate limiting."; + JOptionPane.showMessageDialog( + this, + rateLimitMessage, + "Rate Limit", + JOptionPane.WARNING_MESSAGE); + return; + } + + if (isConnected || connectionStartTime > 0) { + tradeController.disconnect(); + isConnected = false; + connectionStartTime = 0; + try { + Thread.sleep(DISCONNECT_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + lastConnectionAttemptTime = currentTime; + + currentSymbol = symbol; + connectionStartTime = System.currentTimeMillis(); + symbolLabel.setText("---"); + priceLabel.setText("---"); + volumeLabel.setText("---"); + timestampLabel.setText("---"); + + connectButton.setEnabled(false); + disconnectButton.setEnabled(true); + + statusLabel.setText("Status: Connecting..."); + statusLabel.setForeground(Color.ORANGE); + + tradeController.execute(symbol); + + // Start a timeout check for symbol not found + startSymbolNotFoundCheck(); + } + + /** + * Handles the Disconnect button click. + */ + private void onDisconnectClicked() { + tradeController.disconnect(); + resetToDefault(); + } + + /** + * Resets all information to default values. + */ + private void resetToDefault() { + isConnected = false; + connectionStartTime = 0; + currentSymbol = ""; + + symbolLabel.setText("---"); + priceLabel.setText("---"); + volumeLabel.setText("---"); + timestampLabel.setText("---"); + statusLabel.setText("Status: Disconnected"); + statusLabel.setForeground(Color.BLACK); + + connectButton.setEnabled(true); + disconnectButton.setEnabled(false); + connectButton.setText("Connect"); + } + + /** + * Starts a background thread to check if symbol is not found after timeout. + */ + private void startSymbolNotFoundCheck() { + final long checkStartTime = connectionStartTime; + final String checkSymbol = currentSymbol; + + new Thread(() -> { + try { + Thread.sleep(SYMBOL_NOT_FOUND_TIMEOUT); + SwingUtilities.invokeLater(() -> { + long currentTime = System.currentTimeMillis(); + if (checkSymbol.equals(currentSymbol) + && connectionStartTime == checkStartTime + && connectionStartTime > 0 + && currentTime - connectionStartTime + >= SYMBOL_NOT_FOUND_TIMEOUT + && symbolLabel.getText().equals("---")) { + // No trades received - could be symbol not found + // or rate limiting + boolean isCrypto = currentSymbol.contains(":") + && currentSymbol.toUpperCase() + .startsWith("BINANCE:"); + String message = "Connection timeout: Symbol '" + + currentSymbol + + "' not found or no trades available.\n\n"; + if (!isCrypto) { + message += "⚠️ IMPORTANT: Stock symbols " + + "(like AAPL, TSLA) typically do NOT " + + "have real-time trade data on Finnhub.\n" + + "The WebSocket trade feed primarily " + + "supports CRYPTO pairs.\n\n"; + } + message += "This may also occur if:\n" + + "• Requests are made too frequently " + + "(rate limiting)\n" + + "• The symbol format is incorrect\n" + + "• The market is closed\n\n" + + "✅ RECOMMENDED: Use crypto pairs like:\n" + + " • BINANCE:BTCUSDT\n" + + " • BINANCE:ETHUSDT\n" + + " • BINANCE:BNBUSDT\n\n" + + "Please wait " + + (CONNECTION_COOLDOWN_MS / MILLISECONDS_PER_SECOND) + + " seconds before trying again."; + + JOptionPane.showMessageDialog( + TradeView.this, + message, + "Connection Error", + JOptionPane.ERROR_MESSAGE); + statusLabel.setText("Status: Connection failed"); + statusLabel.setForeground(Color.RED); + tradeController.disconnect(); + resetToDefault(); + // Enforce cooldown after timeout + lastConnectionAttemptTime = System.currentTimeMillis(); + startCooldownTimer(); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + + /** + * Starts a cooldown timer that disables the connect button and shows countdown. + */ + private void startCooldownTimer() { + connectButton.setEnabled(false); + new Thread(() -> { + try { + int cooldownSeconds = (int) (CONNECTION_COOLDOWN_MS + / MILLISECONDS_PER_SECOND); + for (int remaining = cooldownSeconds; remaining > 0; remaining--) { + final int seconds = remaining; + SwingUtilities.invokeLater(() -> { + connectButton.setText("Wait " + seconds + "s"); + statusLabel.setText("Status: Rate limited - wait " + + seconds + " second(s)"); + statusLabel.setForeground(Color.ORANGE); + }); + Thread.sleep(ONE_SECOND_MS); + } + SwingUtilities.invokeLater(() -> { + connectButton.setText("Connect"); + connectButton.setEnabled(true); + disconnectButton.setEnabled(false); + statusLabel.setText("Status: Ready to connect"); + statusLabel.setForeground(Color.BLACK); + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + SwingUtilities.invokeLater(() -> { + connectButton.setText("Connect"); + connectButton.setEnabled(true); + disconnectButton.setEnabled(false); + }); + } + }).start(); + } + + /** + * Updates UI labels when a new trade is received. + */ + private void updateTradeOnUi(Trade trade) { + SwingUtilities.invokeLater(() -> { + String symbol = trade.getSymbol(); + double price = trade.getPrice(); + double volume = trade.getVolume(); + Instant timestamp = trade.getTimestamp(); + + symbolLabel.setText(symbol != null ? symbol : "N/A"); + priceLabel.setText(String.format("$%,.2f", price)); + volumeLabel.setText(String.format("%,.4f", volume)); + + if (timestamp != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + String time = timestamp + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(formatter); + timestampLabel.setText(time); + } else { + timestampLabel.setText("N/A"); + } + + // Mark as connected on first trade + connectionStartTime = 0; // Reset timeout on first trade + isConnected = true; + connectButton.setEnabled(false); + disconnectButton.setEnabled(true); + }); + } + + /** + * Updates the status label on the Event Dispatch Thread. + */ + private void updateStatusOnUi(String statusText, boolean isError) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText(statusText); + if (isError) { + statusLabel.setForeground(Color.RED); + // On error, re-enable connect button and disable disconnect + connectButton.setEnabled(true); + disconnectButton.setEnabled(false); + isConnected = false; + + // Combined error handling for rate limiting and symbol not found + boolean isRateLimit = statusText.contains("429") + || statusText.contains("Too Many Requests") + || statusText.contains("Rate limit"); + boolean isOtherError = statusText.contains("Error") + || statusText.contains("Failure"); + + if (isRateLimit || isOtherError) { + String message; + long cooldownSeconds = CONNECTION_COOLDOWN_MS + / MILLISECONDS_PER_SECOND; + if (isRateLimit) { + message = "Connection failed: Too Many Requests (429).\n" + + "This may also occur if the symbol '" + + currentSymbol + "' is invalid.\n\n" + + "Please wait " + cooldownSeconds + + " seconds before trying again.\n" + + "If the problem persists, please verify " + + "the symbol is correct."; + // Enforce cooldown after 429 error + lastConnectionAttemptTime = System.currentTimeMillis(); + startCooldownTimer(); + } else { + message = "Connection failed: Symbol '" + + currentSymbol + + "' not found or invalid.\n" + + "This may also occur if requests are made " + + "too frequently.\n\n" + + "Please wait " + cooldownSeconds + + " seconds before trying again.\n" + + "If the problem persists, please verify " + + "the symbol is correct."; + // Enforce cooldown for other errors too + lastConnectionAttemptTime = System.currentTimeMillis(); + startCooldownTimer(); + } + + JOptionPane.showMessageDialog( + TradeView.this, + message, + "Connection Error", + JOptionPane.ERROR_MESSAGE); + } + } else if (statusText != null + && statusText.contains(STATUS_CONNECTED)) { + statusLabel.setForeground(new Color(0, 128, 0)); + } else if (statusText != null + && statusText.contains(STATUS_CONNECTING)) { + statusLabel.setForeground(Color.ORANGE); + } else if (statusText != null + && statusText.contains(STATUS_DISCONNECTED)) { + statusLabel.setForeground(Color.BLACK); + // On disconnect, reset button states + connectButton.setEnabled(true); + disconnectButton.setEnabled(false); + isConnected = false; + } else { + statusLabel.setForeground(Color.BLACK); + } + }); + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + TradeState state = tradeViewModel.getState(); + if (state != null) { + // Update UI based on ViewModel state + if (state.getCurrentTrade() != null) { + updateTradeOnUi(state.getCurrentTrade()); + } + if (state.getStatusText() != null) { + updateStatusOnUi(state.getStatusText(), state.isError()); + } + } + } + + public String getViewName() { + return this.viewName; + } + + public void setBackNavigation(ViewManagerModel viewManagerModel, String homeViewName) { + this.viewManagerModel = viewManagerModel; + this.homeViewName = homeViewName; + } + + /** + * Sets the market status view model to check if market is open/closed. + */ + public void setMarketStatusViewModel(MarketStatusViewModel marketStatusViewModel) { + this.marketStatusViewModel = marketStatusViewModel; + } +} diff --git a/src/test/java/use_case/earnings_history/GetEarningsHistoryInteractorTest.java b/src/test/java/use_case/earnings_history/GetEarningsHistoryInteractorTest.java new file mode 100644 index 00000000..c45deb8a --- /dev/null +++ b/src/test/java/use_case/earnings_history/GetEarningsHistoryInteractorTest.java @@ -0,0 +1,194 @@ +package use_case.earnings_history; + +import entity.EarningsRecord; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for GetEarningsHistoryInteractor. + */ +public class GetEarningsHistoryInteractorTest { + + /** + * Simple in-memory stub for EarningsDataAccessInterface. + * Configure its fields before each test. + */ + private static class InMemoryEarningsDao implements EarningsDataAccessInterface { + + List recordsToReturn = new ArrayList<>(); + boolean throwException = false; + + String lastSymbol; + + @Override + public List getEarningsFor(String symbol) throws IOException { + lastSymbol = symbol; + if (throwException) { + throw new IOException("boom"); + } + return recordsToReturn; + } + } + + /** + * Recording presenter that stores everything the interactor sends. + * It implements **all** methods in GetEarningsHistoryOutputBoundary. + */ + private static class RecordingPresenter implements GetEarningsHistoryOutputBoundary { + + GetEarningsHistoryOutputData lastSuccess; + String lastSymbolErrorMessage; + String lastConnectionErrorMessage; + String lastNoDataMessage; + + @Override + public void prepareSuccessView(GetEarningsHistoryOutputData outputData) { + lastSuccess = outputData; + } + + @Override + public void prepareSymbolErrorView(String message) { + lastSymbolErrorMessage = message; + } + + @Override + public void prepareConnectionErrorView(String message) { + lastConnectionErrorMessage = message; + } + + @Override + public void prepareNoDataView(String message) { + lastNoDataMessage = message; + } + } + + private EarningsRecord sampleRecord(String period) { + return new EarningsRecord(period, 1.23, 1.00, 0.23); + } + + @Test + void blankSymbol_reportsSymbolError_andDoesNotCallDao() { + InMemoryEarningsDao dao = new InMemoryEarningsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + GetEarningsHistoryInteractor interactor = + new GetEarningsHistoryInteractor(dao, presenter); + + // symbol is blank → invalid + GetEarningsHistoryInputData input = + new GetEarningsHistoryInputData(" "); + + interactor.execute(input); + + assertNull(dao.lastSymbol, "DAO must not be called for blank symbol"); + assertNull(presenter.lastSuccess, "No success result expected"); + assertNull(presenter.lastConnectionErrorMessage); + assertNull(presenter.lastNoDataMessage); + assertNotNull(presenter.lastSymbolErrorMessage, "Symbol error should be reported"); + assertTrue(presenter.lastSymbolErrorMessage.toLowerCase().contains("symbol"), + "Error message should mention symbol"); + } + + @Test + void daoThrowsIOException_reportsConnectionError() { + InMemoryEarningsDao dao = new InMemoryEarningsDao(); + dao.throwException = true; + + RecordingPresenter presenter = new RecordingPresenter(); + GetEarningsHistoryInteractor interactor = + new GetEarningsHistoryInteractor(dao, presenter); + + GetEarningsHistoryInputData input = + new GetEarningsHistoryInputData("AAPL"); + + interactor.execute(input); + + assertEquals("AAPL", dao.lastSymbol); + assertNull(presenter.lastSuccess); + assertNull(presenter.lastSymbolErrorMessage); + assertNull(presenter.lastNoDataMessage); + assertNotNull(presenter.lastConnectionErrorMessage, + "Connection/API error should be reported"); + } + + @Test + void daoReturnsNull_reportsSymbolNotSupported() { + InMemoryEarningsDao dao = new InMemoryEarningsDao(); + dao.recordsToReturn = null; // contract: null = company not found / unsupported + + RecordingPresenter presenter = new RecordingPresenter(); + GetEarningsHistoryInteractor interactor = + new GetEarningsHistoryInteractor(dao, presenter); + + GetEarningsHistoryInputData input = + new GetEarningsHistoryInputData("AAPL"); + + interactor.execute(input); + + assertEquals("AAPL", dao.lastSymbol); + assertNull(presenter.lastSuccess); + assertNull(presenter.lastConnectionErrorMessage); + assertNull(presenter.lastNoDataMessage); + assertNotNull(presenter.lastSymbolErrorMessage); + String msg = presenter.lastSymbolErrorMessage.toLowerCase(); + assertTrue(msg.contains("not found") || msg.contains("not supported"), + "Error should mention company not found/unsupported"); + } + + @Test + void daoReturnsEmptyList_reportsNoData() { + InMemoryEarningsDao dao = new InMemoryEarningsDao(); + dao.recordsToReturn = List.of(); // valid symbol, but no earnings + + RecordingPresenter presenter = new RecordingPresenter(); + GetEarningsHistoryInteractor interactor = + new GetEarningsHistoryInteractor(dao, presenter); + + GetEarningsHistoryInputData input = + new GetEarningsHistoryInputData("AAPL"); + + interactor.execute(input); + + assertEquals("AAPL", dao.lastSymbol); + assertNull(presenter.lastSuccess); + assertNull(presenter.lastSymbolErrorMessage); + assertNull(presenter.lastConnectionErrorMessage); + assertNotNull(presenter.lastNoDataMessage); + String msg = presenter.lastNoDataMessage.toLowerCase(); + assertTrue(msg.contains("no earnings") || msg.contains("no data"), + "No-data message should mention missing earnings"); + } + + @Test + void daoReturnsRecords_callsSuccessWithThoseRecords() { + InMemoryEarningsDao dao = new InMemoryEarningsDao(); + dao.recordsToReturn = List.of( + sampleRecord("2024-Q1"), + sampleRecord("2023-Q4") + ); + + RecordingPresenter presenter = new RecordingPresenter(); + GetEarningsHistoryInteractor interactor = + new GetEarningsHistoryInteractor(dao, presenter); + + GetEarningsHistoryInputData input = + new GetEarningsHistoryInputData("AAPL"); + + interactor.execute(input); + + assertEquals("AAPL", dao.lastSymbol); + assertNull(presenter.lastSymbolErrorMessage); + assertNull(presenter.lastConnectionErrorMessage); + assertNull(presenter.lastNoDataMessage); + assertNotNull(presenter.lastSuccess, "Success output expected"); + + List records = presenter.lastSuccess.getRecords(); + assertEquals(2, records.size()); + assertEquals("2024-Q1", records.get(0).getPeriod()); + assertEquals("2023-Q4", records.get(1).getPeriod()); + } +} diff --git a/src/test/java/use_case/filter_search/FilterSearchInteractorTest.java b/src/test/java/use_case/filter_search/FilterSearchInteractorTest.java new file mode 100644 index 00000000..cc7ac07e --- /dev/null +++ b/src/test/java/use_case/filter_search/FilterSearchInteractorTest.java @@ -0,0 +1,11 @@ +package use_case.filter_search; + +import org.junit.jupiter.api.Test; + +public class FilterSearchInteractorTest { + + @Test + void successTest() { + + } +} diff --git a/src/test/java/use_case/market_status/MarketStatusInteractorTest.java b/src/test/java/use_case/market_status/MarketStatusInteractorTest.java new file mode 100644 index 00000000..233f763c --- /dev/null +++ b/src/test/java/use_case/market_status/MarketStatusInteractorTest.java @@ -0,0 +1,160 @@ +package use_case.market_status; + +import entity.MarketStatus; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for MarketStatusInteractor. + */ +public class MarketStatusInteractorTest { + + /** + * Simple in-memory stub for MarketStatusDataAccessInterface. + */ + private static class InMemoryMarketStatusDao implements MarketStatusDataAccessInterface { + + MarketStatus statusToReturn; + boolean throwOnLoad = false; + int loadCalls = 0; + + @Override + public MarketStatus loadStatus() throws Exception { + loadCalls++; + if (throwOnLoad) { + throw new Exception("Data access error"); + } + return statusToReturn; + } + } + + /** + * Recording presenter that just stores what the interactor sends. + */ + private static class RecordingPresenter implements MarketStatusOutputBoundary { + + MarketStatusResponseModel lastSuccess; + String lastError; + + @Override + public void prepareSuccessView(MarketStatusResponseModel responseModel) { + lastSuccess = responseModel; + } + + @Override + public void prepareFailView(String errorMessage) { + lastError = errorMessage; + } + } + + @Test + void execute_success_openMarket() { + // Arrange + InMemoryMarketStatusDao dao = new InMemoryMarketStatusDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.statusToReturn = new MarketStatus( + "US", + true, // open + "regular", // session + "", // holiday + 1_700_000_000L, // timestamp + "America/New_York" // timezone + ); + + MarketStatusInteractor interactor = + new MarketStatusInteractor(dao, presenter); + + // Act + interactor.execute(); + + // Assert DAO called + assertEquals(1, dao.loadCalls, "DAO should be called exactly once"); + + // Assert presenter got success, not error + assertNull(presenter.lastError, "No error should be reported"); + assertNotNull(presenter.lastSuccess, "Success response should be sent"); + + MarketStatus status = presenter.lastSuccess.getMarketStatus(); + assertNotNull(status); + assertEquals("US", status.getExchange()); + assertTrue(status.isOpen()); + assertEquals("regular", status.getSession()); + } + + @Test + void execute_success_closedHoliday() { + // Arrange + InMemoryMarketStatusDao dao = new InMemoryMarketStatusDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.statusToReturn = new MarketStatus( + "US", + false, // closed + "", // session + "Memorial Day", // holiday + 1_700_000_000L, + "America/New_York" + ); + + MarketStatusInteractor interactor = + new MarketStatusInteractor(dao, presenter); + + // Act + interactor.execute(); + + // Assert + assertEquals(1, dao.loadCalls); + assertNull(presenter.lastError); + assertNotNull(presenter.lastSuccess); + + MarketStatus status = presenter.lastSuccess.getMarketStatus(); + assertFalse(status.isOpen()); + assertEquals("Memorial Day", status.getHoliday()); + } + + @Test + void execute_nullStatus_triggersFailView() { + // Arrange + InMemoryMarketStatusDao dao = new InMemoryMarketStatusDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.statusToReturn = null; // simulate no data + + MarketStatusInteractor interactor = + new MarketStatusInteractor(dao, presenter); + + // Act + interactor.execute(); + + // Assert + assertEquals(1, dao.loadCalls); + assertNull(presenter.lastSuccess, "No success should be sent"); + assertNotNull(presenter.lastError, "Error should be reported"); + assertTrue(presenter.lastError.contains("No market status available"), + "Error message should mention missing status"); + } + + @Test + void execute_daoThrows_triggersFailView() { + // Arrange + InMemoryMarketStatusDao dao = new InMemoryMarketStatusDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.throwOnLoad = true; + + MarketStatusInteractor interactor = + new MarketStatusInteractor(dao, presenter); + + // Act + interactor.execute(); + + // Assert + assertEquals(1, dao.loadCalls); + assertNull(presenter.lastSuccess); + assertNotNull(presenter.lastError); + assertTrue(presenter.lastError.contains("Unable to load market status"), + "Error message should mention inability to load status"); + } +} \ No newline at end of file diff --git a/src/test/java/use_case/news/NewsInteractorTest.java b/src/test/java/use_case/news/NewsInteractorTest.java new file mode 100644 index 00000000..afbe96f5 --- /dev/null +++ b/src/test/java/use_case/news/NewsInteractorTest.java @@ -0,0 +1,201 @@ +package use_case.news; + +import entity.NewsArticle; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for NewsInteractor. + */ +public class NewsInteractorTest { + + /** + * Simple in-memory stub for NewsDataAccessInterface. + * You configure its fields before running each test. + */ + private static class InMemoryNewsDao implements NewsDataAccessInterface { + + List marketNewsToReturn = new ArrayList<>(); + List companyNewsToReturn = new ArrayList<>(); + + boolean throwOnMarket = false; + boolean throwOnCompany = false; + + // capture last call arguments + String lastCompanySymbol; + String lastCompanyFrom; + String lastCompanyTo; + + @Override + public List loadMarketNews() throws Exception { + if (throwOnMarket) { + throw new Exception("Market error"); + } + return marketNewsToReturn; + } + + @Override + public List loadCompanyNews(String symbol, String fromDate, String toDate) throws Exception { + lastCompanySymbol = symbol; + lastCompanyFrom = fromDate; + lastCompanyTo = toDate; + + if (throwOnCompany) { + throw new Exception("Company error"); + } + return companyNewsToReturn; + } + } + + /** + * Recording presenter that just stores what the interactor sends. + */ + private static class RecordingPresenter implements NewsOutputBoundary { + + NewsResponseModel lastSuccess; + String lastError; + + @Override + public void prepareSuccessView(NewsResponseModel responseModel) { + lastSuccess = responseModel; + } + + @Override + public void prepareFailView(String errorMessage) { + lastError = errorMessage; + } + } + + private NewsArticle sampleArticle(String symbol) { + return new NewsArticle( + 1L, + "general", + 1_700_000_000L, + "Test headline for " + symbol, + "", + symbol, + "TestSource", + "Test summary", + "https://example.com/article" + ); + } + + @Test + void executeMarketNews_success_callsPresenterWithArticles() { + // Arrange + InMemoryNewsDao dao = new InMemoryNewsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + dao.marketNewsToReturn = List.of(sampleArticle("AAPL"), sampleArticle("MSFT")); + + NewsInteractor interactor = new NewsInteractor(dao, presenter); + + NewsRequestModel request = new NewsRequestModel(); // market news + + // Act + interactor.executeMarketNews(request); + + // Assert + assertNull(presenter.lastError, "No error should be reported"); + assertNotNull(presenter.lastSuccess, "Success response should be sent"); + List articles = presenter.lastSuccess.getArticles(); + assertEquals(2, articles.size()); + assertEquals("AAPL", articles.get(0).getRelated()); + assertEquals("MSFT", articles.get(1).getRelated()); + } + + @Test + void executeMarketNews_error_callsFailView() { + // Arrange + InMemoryNewsDao dao = new InMemoryNewsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + dao.throwOnMarket = true; + + NewsInteractor interactor = new NewsInteractor(dao, presenter); + NewsRequestModel request = new NewsRequestModel(); + + // Act + interactor.executeMarketNews(request); + + // Assert + assertNull(presenter.lastSuccess, "No success response expected"); + assertNotNull(presenter.lastError, "Error should be reported"); + assertTrue(presenter.lastError.contains("Unable to load market news"), + "Error message should mention market news"); + } + + @Test + void executeCompanyNews_emptySymbol_reportsError() { + // Arrange + InMemoryNewsDao dao = new InMemoryNewsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + NewsInteractor interactor = new NewsInteractor(dao, presenter); + + // symbol is blank → invalid + NewsRequestModel request = new NewsRequestModel(" ", "2024-01-01", "2024-01-31"); + + // Act + interactor.executeCompanyNews(request); + + // Assert + assertNull(presenter.lastSuccess, "No success response expected"); + assertEquals("Symbol is required for company news.", presenter.lastError); + // Also check DAO was never called + assertNull(dao.lastCompanySymbol, "DAO should not be called on invalid symbol"); + } + + @Test + void executeCompanyNews_success_passesArticlesAndArgs() { + // Arrange + InMemoryNewsDao dao = new InMemoryNewsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.companyNewsToReturn = List.of(sampleArticle("AAPL")); + + NewsInteractor interactor = new NewsInteractor(dao, presenter); + + NewsRequestModel request = + new NewsRequestModel("AAPL", "2024-01-01", "2024-01-31"); + + // Act + interactor.executeCompanyNews(request); + + // Assert DAO was called with correct arguments + assertEquals("AAPL", dao.lastCompanySymbol); + assertEquals("2024-01-01", dao.lastCompanyFrom); + assertEquals("2024-01-31", dao.lastCompanyTo); + + // Assert presenter got success + assertNull(presenter.lastError); + assertNotNull(presenter.lastSuccess); + List articles = presenter.lastSuccess.getArticles(); + assertEquals(1, articles.size()); + assertEquals("AAPL", articles.get(0).getRelated()); + } + + @Test + void executeCompanyNews_noArticles_triggersFailView() { + // Arrange + InMemoryNewsDao dao = new InMemoryNewsDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + dao.companyNewsToReturn = List.of(); // empty list + + NewsInteractor interactor = new NewsInteractor(dao, presenter); + + NewsRequestModel request = + new NewsRequestModel("AAPL", "2024-01-01", "2024-01-31"); + + // Act + interactor.executeCompanyNews(request); + + // Assert + assertNull(presenter.lastSuccess); + assertNotNull(presenter.lastError); + assertTrue(presenter.lastError.contains("No news found for symbol"), + "Should report no news found"); + } +} diff --git a/src/test/java/use_case/stock_search/StockSearchInteractorTest.java b/src/test/java/use_case/stock_search/StockSearchInteractorTest.java new file mode 100644 index 00000000..127a3152 --- /dev/null +++ b/src/test/java/use_case/stock_search/StockSearchInteractorTest.java @@ -0,0 +1,134 @@ +package use_case.stock_search; + +import entity.StockQuote; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StockSearchInteractorTest { + + /** + * Simple in-memory stub for StockSearchDataAccessInterface. + */ + private static class InMemoryStockSearchDao implements StockSearchDataAccessInterface { + + StockQuote quoteToReturn; + boolean throwOnLoad = false; + String lastSymbol; + + @Override + public StockQuote loadQuote(String symbol) throws Exception { + lastSymbol = symbol; + if (throwOnLoad) { + throw new Exception("Load error"); + } + return quoteToReturn; + } + } + + /** + * Recording presenter that captures the last success or error. + */ + private static class RecordingPresenter implements StockSearchOutputBoundary { + + StockSearchResponseModel lastSuccess; + String lastError; + + @Override + public void prepareSuccessView(StockSearchResponseModel responseModel) { + lastSuccess = responseModel; + } + + @Override + public void prepareFailView(String errorMessage) { + lastError = errorMessage; + } + } + + private StockQuote sampleQuote(String symbol) { + return new StockQuote( + symbol, + "Sample Company", + "NASDAQ", + "Technology", + 1000.0, + 150.0, + 148.0, + 151.0, + 147.5, + 149.0, + 1_700_000_000L + ); + } + + @Test + void execute_success_loadsQuoteAndCallsPresenter() throws Exception { + // Arrange + InMemoryStockSearchDao dao = new InMemoryStockSearchDao(); + RecordingPresenter presenter = new RecordingPresenter(); + dao.quoteToReturn = sampleQuote("AAPL"); + + StockSearchInteractor interactor = new StockSearchInteractor(dao, presenter); + + StockSearchRequestModel request = new StockSearchRequestModel("aapl"); + + // Act + interactor.execute(request); + + // Assert DAO was called with trimmed/uppercased symbol + assertEquals("AAPL", dao.lastSymbol); + + // Assert presenter received success + assertNotNull(presenter.lastSuccess); + assertNull(presenter.lastError); + + StockSearchResponseModel response = presenter.lastSuccess; + assertEquals("AAPL", response.getSymbol()); + assertEquals("Sample Company", response.getCompanyName()); + assertEquals("NASDAQ", response.getExchange()); + assertEquals("Technology", response.getIndustry()); + assertEquals(1000.0, response.getMarketCap()); + assertEquals(150.0, response.getCurrentPrice()); + } + + @Test + void execute_blankSymbol_reportsErrorAndDoesNotCallDao() throws Exception { + // Arrange + InMemoryStockSearchDao dao = new InMemoryStockSearchDao(); + RecordingPresenter presenter = new RecordingPresenter(); + + StockSearchInteractor interactor = new StockSearchInteractor(dao, presenter); + + StockSearchRequestModel request = new StockSearchRequestModel(" "); + + // Act + interactor.execute(request); + + // Assert: DAO should not be called + assertNull(dao.lastSymbol); + + // Assert: error message from presenter + assertNull(presenter.lastSuccess); + assertEquals("Please enter a symbol.", presenter.lastError); + } + + @Test + void execute_daoThrowsException_reportsError() throws Exception { + // Arrange + InMemoryStockSearchDao dao = new InMemoryStockSearchDao(); + RecordingPresenter presenter = new RecordingPresenter(); + dao.throwOnLoad = true; + + StockSearchInteractor interactor = new StockSearchInteractor(dao, presenter); + + StockSearchRequestModel request = new StockSearchRequestModel("AAPL"); + + // Act + interactor.execute(request); + + // Assert: presenter should receive an error message + assertNull(presenter.lastSuccess); + assertNotNull(presenter.lastError); + assertTrue(presenter.lastError.startsWith("Unable to load quote.")); + } +} diff --git a/src/test/java/use_case/trade/TradeInteractorTest.java b/src/test/java/use_case/trade/TradeInteractorTest.java new file mode 100644 index 00000000..f0d3fe74 --- /dev/null +++ b/src/test/java/use_case/trade/TradeInteractorTest.java @@ -0,0 +1,288 @@ +package use_case.trade; + +import entity.Trade; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for TradeInteractor. + */ +class TradeInteractorTest { + + /** + * Simple in-memory stub for TradeDataAccessInterface. + */ + private static class InMemoryTradeDataAccess implements TradeDataAccessInterface { + + TradeListener storedListener; + String lastSymbol; + boolean throwOnConnect = false; + boolean throwOnDisconnect = false; + int connectCalls = 0; + int disconnectCalls = 0; + + @Override + public void connect(String symbol, TradeListener listener) { + connectCalls++; + lastSymbol = symbol; + storedListener = listener; + if (throwOnConnect) { + throw new RuntimeException("Connection error"); + } + } + + @Override + public void disconnect() { + disconnectCalls++; + if (throwOnDisconnect) { + throw new RuntimeException("Disconnection error"); + } + } + } + + /** + * Recording presenter that captures the last success, error, or status update. + */ + private static class RecordingPresenter implements TradeOutputBoundary { + + TradeResponseModel lastSuccess; + String lastError; + String lastStatusText; + Boolean lastStatusIsError; + + @Override + public void prepareSuccessView(TradeResponseModel responseModel) { + lastSuccess = responseModel; + } + + @Override + public void prepareFailView(String errorMessage) { + lastError = errorMessage; + } + + @Override + public void prepareStatusView(String statusText, boolean isError) { + lastStatusText = statusText; + lastStatusIsError = isError; + } + } + + @Test + void execute_success_validSymbol() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel("AAPL"); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(1, dataAccess.connectCalls, "DAO should be called exactly once"); + assertEquals("AAPL", dataAccess.lastSymbol, "DAO should receive the correct symbol"); + assertNotNull(dataAccess.storedListener, "Listener should be stored"); + assertNull(presenter.lastError, "No error should be reported"); + } + + @Test + void execute_failure_emptySymbol() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel(""); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(0, dataAccess.connectCalls, "DAO should not be called"); + assertNotNull(presenter.lastError, "Error should be reported"); + assertEquals("Please enter a symbol.", presenter.lastError); + assertNull(presenter.lastSuccess, "No success should be sent"); + } + + @Test + void execute_failure_nullSymbol() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel(null); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(0, dataAccess.connectCalls, "DAO should not be called"); + assertNotNull(presenter.lastError, "Error should be reported"); + assertEquals("Please enter a symbol.", presenter.lastError); + } + + @Test + void execute_failure_whitespaceOnlySymbol() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel(" "); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(0, dataAccess.connectCalls, "DAO should not be called"); + assertNotNull(presenter.lastError, "Error should be reported"); + assertEquals("Please enter a symbol.", presenter.lastError); + } + + @Test + void execute_success_trimsWhitespace() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel(" AAPL "); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(1, dataAccess.connectCalls); + assertEquals("AAPL", dataAccess.lastSymbol, "Symbol should be trimmed"); + } + + @Test + void execute_failure_connectionException() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + dataAccess.throwOnConnect = true; + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel("AAPL"); + + // Act + interactor.execute(requestModel); + + // Assert + assertEquals(1, dataAccess.connectCalls); + assertNotNull(presenter.lastError, "Error should be reported"); + assertTrue(presenter.lastError.contains("Unable to connect to trade feed"), + "Error message should mention connection failure"); + assertTrue(presenter.lastError.contains("Connection error"), + "Error message should include exception message"); + } + + @Test + void execute_success_listenerOnTradeCalled() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel("BINANCE:BTCUSDT"); + + // Act + interactor.execute(requestModel); + + // Simulate trade data arriving via listener callback + Trade trade = new Trade("BINANCE:BTCUSDT", 50000.0, 1.5, Instant.now()); + dataAccess.storedListener.onTrade(trade); + + // Assert + assertNotNull(presenter.lastSuccess, "Success view should be prepared"); + assertNotNull(presenter.lastSuccess.getTrade(), "Trade should be in response model"); + assertEquals("BINANCE:BTCUSDT", presenter.lastSuccess.getTrade().getSymbol()); + assertEquals(50000.0, presenter.lastSuccess.getTrade().getPrice()); + assertEquals(1.5, presenter.lastSuccess.getTrade().getVolume()); + } + + @Test + void execute_success_listenerOnStatusChangedCalled() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel("AAPL"); + + // Act + interactor.execute(requestModel); + + // Simulate status update via listener callback + dataAccess.storedListener.onStatusChanged("Status: Connected", false); + + // Assert + assertNotNull(presenter.lastStatusText, "Status should be recorded"); + assertEquals("Status: Connected", presenter.lastStatusText); + assertFalse(presenter.lastStatusIsError, "Should not be an error status"); + } + + @Test + void execute_success_listenerOnStatusChangedError() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + TradeRequestModel requestModel = new TradeRequestModel("AAPL"); + + // Act + interactor.execute(requestModel); + + // Simulate error status via listener callback + dataAccess.storedListener.onStatusChanged("Status: Error - Symbol not found", true); + + // Assert + assertEquals("Status: Error - Symbol not found", presenter.lastStatusText); + assertTrue(presenter.lastStatusIsError, "Should be an error status"); + } + + @Test + void disconnect_success() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + + // Act + interactor.disconnect(); + + // Assert + assertEquals(1, dataAccess.disconnectCalls, "DAO disconnect should be called"); + assertNull(presenter.lastError, "No error should be reported"); + } + + @Test + void disconnect_failure_exception() { + // Arrange + InMemoryTradeDataAccess dataAccess = new InMemoryTradeDataAccess(); + RecordingPresenter presenter = new RecordingPresenter(); + dataAccess.throwOnDisconnect = true; + + TradeInteractor interactor = new TradeInteractor(dataAccess, presenter); + + // Act + interactor.disconnect(); + + // Assert + assertEquals(1, dataAccess.disconnectCalls); + assertNotNull(presenter.lastError, "Error should be reported"); + assertTrue(presenter.lastError.contains("Unable to disconnect"), + "Error message should mention disconnection failure"); + assertTrue(presenter.lastError.contains("Disconnection error"), + "Error message should include exception message"); + } +} +