Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d33c7e0f52 | |||
| 79930c40ac | |||
| 448278243e | |||
| ec3e4dde75 | |||
| 596be63554 | |||
| 8632f0e94b | |||
| 28ae2bd737 | |||
| c806524e0c | |||
| fea83153ba | |||
| 4716ef03ba | |||
| 3b76de0831 | |||
| e94a6f8d5b | |||
| 1b1324d0f9 | |||
| 71a5a32198 | |||
| 3dbf194320 | |||
| a29a50e825 | |||
| daeff1ce93 | |||
| 298172c00b | |||
| df677b38fb | |||
| c344e47ae6 | |||
| 209af50a4c | |||
| 1d0d44dc29 | |||
| c4d6403721 | |||
| 84cab94beb | |||
| d17683cd67 | |||
| 39537c0568 | |||
| b768b67641 | |||
| 25147deb7f | |||
| 4030bef7a8 | |||
| c6964f0310 | |||
| 9a9f203af2 | |||
| 174086defc | |||
| 43c9d3b3b6 | |||
| 39724b61d6 | |||
| d9588f8f65 | |||
| 6ce6153ccf | |||
| ec2d4f9fbc | |||
| a19be31381 | |||
| 9c3f012da7 | |||
| 8ebbc16bcd | |||
| c177193438 | |||
| 7c07bc59e4 | |||
| e4a8d371f7 | |||
| 1c0e04cb0d | |||
| c3f6ef531b | |||
| a67a0993d6 | |||
| bc43e4c44a | |||
| 9b2dcd7377 | |||
| 1eda50ad13 | |||
| 506a644c6b | |||
| 555e156b5e | |||
| b67e18f2fe | |||
| 09c9712191 | |||
| 6258dcdff1 | |||
| 605b050177 |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
67
.serena/project.yml
Normal file
67
.serena/project.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||||
|
# * For C, use cpp
|
||||||
|
# * For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
language: typescript
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "opendata"
|
||||||
189
changelog.md
189
changelog.md
@@ -1,5 +1,194 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-01 - 3.1.0 - feat(fundamentals)
|
||||||
|
Add FundamentalsService and SEC EDGAR provider with caching, rate-limiting, tests, and docs updates
|
||||||
|
|
||||||
|
- Introduce FundamentalsService to manage fundamentals providers, caching, retry logic and provider statistics
|
||||||
|
- Add SecEdgarProvider to fetch SEC EDGAR company facts (CIK lookup, company facts parsing) with rate limiting and local caches
|
||||||
|
- Expose fundamentals interfaces and services from ts/stocks (exports updated)
|
||||||
|
- Add comprehensive tests for FundamentalsService and SecEdgarProvider (new test files)
|
||||||
|
- Update README with new Fundamentals module documentation, usage examples, and configuration guidance
|
||||||
|
- Implement caching and TTL handling for fundamentals data and provider-specific cache TTL support
|
||||||
|
- Add .claude/settings.local.json (local permissions) and various test improvements
|
||||||
|
|
||||||
|
## 2025-10-31 - 3.0.0 - BREAKING CHANGE(stocks)
|
||||||
|
Unify stock provider API to discriminated IStockDataRequest and add company name/fullname enrichment
|
||||||
|
|
||||||
|
- Replace legacy provider methods (fetchPrice/fetchPrices) with a single fetchData(request: IStockDataRequest) on IStockProvider — providers must be migrated to the new signature.
|
||||||
|
- Migrate StockPriceService to the unified getData(request: IStockDataRequest) API. Convenience helpers getPrice/getPrices now wrap getData.
|
||||||
|
- Add companyName and companyFullName fields to IStockPrice and populate them in provider mappings (Marketstack mapping updated; Yahoo provider updated to support the unified API).
|
||||||
|
- MarketstackProvider: added buildCompanyFullName helper and improved mapping to include company identification fields and full name formatting.
|
||||||
|
- YahooFinanceProvider: updated to implement fetchData and to route current/batch requests through the new unified request types; historical/intraday throw explicit errors.
|
||||||
|
- Updated tests to exercise the new unified API, company-name enrichment, caching behavior, and provider direct methods.
|
||||||
|
- Note: This is a breaking change for external providers and integrations that implemented the old fetchPrice/fetchPrices API. Bump major version.
|
||||||
|
|
||||||
|
## 2025-10-31 - 2.1.0 - feat(stocks)
|
||||||
|
Add unified stock data API (getData) with historical/OHLCV support, smart caching and provider enhancements
|
||||||
|
|
||||||
|
- Introduce discriminated union request types (IStockDataRequest) and a unified getData() method (replaces legacy getPrice/getPrices for new use cases)
|
||||||
|
- Add OHLCV fields (open, high, low, volume, adjusted) and metadata (dataType, fetchedAt) to IStockPrice
|
||||||
|
- Implement data-type aware smart caching with TTLs (historical = never expire, EOD = 24h, live = 30s, intraday matches interval)
|
||||||
|
- Extend StockPriceService: new getData(), data-specific cache keys, cache maxEntries increased (default 10000), and TTL-aware add/get cache logic
|
||||||
|
- Enhance Marketstack provider: unified fetchData(), historical date-range retrieval with pagination, exchange filtering, batch current fetch, OHLCV mapping, and intraday placeholder
|
||||||
|
- Update Yahoo provider to include dataType and fetchedAt (live data) and maintain legacy fetchPrice/fetchPrices compatibility
|
||||||
|
- Add/adjust tests to cover unified API, historical retrieval, OHLCV presence and smart caching behavior; test setup updated to require explicit OpenData directory paths
|
||||||
|
- Update README to document v2.1 changes, migration examples, and new stock provider capabilities
|
||||||
|
|
||||||
|
## 2025-10-31 - 2.0.0 - BREAKING CHANGE(OpenData)
|
||||||
|
Require explicit directory paths for OpenData (nogit/download/germanBusinessData); remove automatic .nogit creation; update HandelsRegister, JsonlDataProcessor, tests and README.
|
||||||
|
|
||||||
|
- Breaking: OpenData constructor now requires a config object with nogitDir, downloadDir and germanBusinessDataDir. The constructor will throw if these paths are not provided.
|
||||||
|
- Removed automatic creation/export of .nogit/download/germanBusinessData from ts/paths. OpenData.start now ensures the required directories exist.
|
||||||
|
- HandelsRegister API changed: constructor now accepts downloadDir and manages its own unique download folder; screenshot and download paths now use the configured downloadDir.
|
||||||
|
- JsonlDataProcessor now accepts a germanBusinessDataDir parameter and uses it when ensuring/storing data instead of relying on global paths.
|
||||||
|
- Updated tests to provide explicit path configuration (tests now set testNogitDir, testDownloadDir, testGermanBusinessDataDir and write outputs accordingly) and to use updated constructors and qenv usage.
|
||||||
|
- Documentation updated (README) to document the breaking change and show examples for required directory configuration when instantiating OpenData.
|
||||||
|
- Added .claude/settings.local.json for local permissions/config used in development/CI environments.
|
||||||
|
|
||||||
|
## 2025-10-11 - 1.7.0 - feat(stocks)
|
||||||
|
Add Marketstack provider (EOD) with tests, exports and documentation updates
|
||||||
|
|
||||||
|
- Add MarketstackProvider implementation (ts/stocks/providers/provider.marketstack.ts) providing EOD single and batch fetching, availability checks and mapping to IStockPrice.
|
||||||
|
- Export MarketstackProvider from ts/stocks/index.ts so it is available via the public API.
|
||||||
|
- Add comprehensive Marketstack tests (test/test.marketstack.node.ts) covering registration, health checks, single/batch fetches, caching, ticker/market validation, provider stats and sample output.
|
||||||
|
- Update README with Marketstack usage examples, configuration, API key instructions and provider/health documentation.
|
||||||
|
- Bump dev dependency @git.zone/tstest to ^2.4.2 in package.json.
|
||||||
|
- Add project helper/config files (.claude/settings.local.json, .serena/project.yml and .serena/.gitignore) to support CI/tooling.
|
||||||
|
|
||||||
|
## 2025-09-24 - 1.6.1 - fix(stocks)
|
||||||
|
Fix Yahoo provider request handling and bump dependency versions
|
||||||
|
|
||||||
|
- Refactored Yahoo Finance provider to use SmartRequest.create() builder and await response.json() for HTTP responses (replaces direct getJson usage).
|
||||||
|
- Improved batch and single-price fetching to use the SmartRequest API, keeping User-Agent header and timeouts.
|
||||||
|
- Added a compile-time type-check alias to ensure IStockPrice matches tsclass.finance.IStockPrice.
|
||||||
|
- Bumped development and runtime dependency versions (notable bumps include @git.zone/tsbuild, @git.zone/tstest, @push.rocks/qenv, @push.rocks/smartarchive, @push.rocks/smartdata, @push.rocks/smartfile, @push.rocks/smartlog, @push.rocks/smartpath, @push.rocks/smartrequest, @tsclass/tsclass).
|
||||||
|
- Added .claude/settings.local.json to grant local CI permissions for a few Bash commands.
|
||||||
|
|
||||||
|
## 2025-07-12 - 1.6.0 - feat(readme)
|
||||||
|
Revamp documentation and package description for enhanced clarity
|
||||||
|
|
||||||
|
- Restructured README to highlight real-time stock data and German business data, streamlining quick start and advanced examples
|
||||||
|
- Updated package.json description to better reflect library capabilities
|
||||||
|
- Added .claude/settings.local.json to define permissions for external tools
|
||||||
|
- Refined code examples in tests and documentation for improved clarity and consistency
|
||||||
|
|
||||||
|
## 2025-04-09 - 1.5.3 - fix(test)
|
||||||
|
Await file writes in Handelsregister tests to ensure all downloads complete before test end
|
||||||
|
|
||||||
|
- Replaced array.map with await Promise.all to properly await asynchronous file writes in test/test.handelsregister.ts
|
||||||
|
- Improved robustness of asynchronous operations in test suite
|
||||||
|
|
||||||
|
## 2025-04-09 - 1.5.2 - fix(readme)
|
||||||
|
Improve .env configuration code block formatting in documentation
|
||||||
|
|
||||||
|
- Wrap the .env variables block in triple backticks for clarity
|
||||||
|
- Ensure consistency in the Markdown styling of code snippets
|
||||||
|
|
||||||
|
## 2025-04-09 - 1.5.1 - fix(core)
|
||||||
|
No changes detected in project files or documentation. This commit is a placeholder to record that nothing was updated.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-04-09 - 1.5.0 - feat(documentation)
|
||||||
|
Enhance project metadata and documentation with comprehensive usage examples, updated descriptions, and improved keywords.
|
||||||
|
|
||||||
|
- Updated npmextra.json and package.json to refine the project description and keyword list.
|
||||||
|
- Expanded readme.md with detailed sections on environment setup, CRUD operations, bulk JSONL processing, and advanced Handelsregister integrations.
|
||||||
|
- Included advanced workflow examples and error handling strategies in the documentation.
|
||||||
|
- Adjusted test cases (e.g. in test/test.handelsregister.ts) to reflect changes in company name usage.
|
||||||
|
|
||||||
|
## 2025-04-08 - 1.4.6 - fix(tests & jsonl)
|
||||||
|
Improve test structure and refine JSONL parsing for incomplete data
|
||||||
|
|
||||||
|
- Refactored test files to remove redundant get-specific-company tests in test.ts and added missing tests in test.handelsregister.ts
|
||||||
|
- Updated JSONL data processor to conditionally parse remaining data when available
|
||||||
|
|
||||||
|
## 2025-04-05 - 1.4.5 - fix(metadata)
|
||||||
|
Update repository, bugs, and homepage URLs to code.foss.global
|
||||||
|
|
||||||
|
- Repository URL updated from gitlab.com to code.foss.global
|
||||||
|
- Bugs URL updated from gitlab.com to code.foss.global
|
||||||
|
- Homepage URL updated to code.foss.global
|
||||||
|
|
||||||
|
## 2025-04-05 - 1.4.4 - fix(dependencies & tests)
|
||||||
|
Update dependency versions and refine test search query
|
||||||
|
|
||||||
|
- Bumped versions for several dependencies in package.json, including @git.zone/tsbuild, @git.zone/tsbundle, @git.zone/tstest, @push.rocks/tapbundle, @push.rocks/smartdata, @push.rocks/smartfile, @push.rocks/smartpromise, @push.rocks/smartrequest, and @tsclass/tsclass
|
||||||
|
- Updated test file to replace the search query 'Volkswagen' with 'LADR'
|
||||||
|
- Re-enabled the build initial data test by removing tap.skip
|
||||||
|
|
||||||
|
## 2025-01-07 - 1.4.3 - fix(test)
|
||||||
|
Corrected index value in test for fetching specific company data
|
||||||
|
|
||||||
|
- Updated the index from 8 to 7 for the germanParsedRegistration fetch in test
|
||||||
|
|
||||||
|
## 2025-01-07 - 1.4.2 - fix(core)
|
||||||
|
Fix concurrency and download handling in HandelsRegister class and adjust test cases
|
||||||
|
|
||||||
|
- Improved the clickFindButton function to include an argument for results limit.
|
||||||
|
- Enhanced the downloadFile function to rename and ensure files are correctly handled.
|
||||||
|
- Updated searchCompany method to allow specifying a limit on the number of search results.
|
||||||
|
- Adjusted test cases to select specific test data indices and output test files to a dedicated directory.
|
||||||
|
|
||||||
|
## 2025-01-04 - 1.4.1 - fix(core)
|
||||||
|
Fix issues with JSONL data processing and improve error handling in business record validation
|
||||||
|
|
||||||
|
- Fixed JSONL data processing by adding concurrent processing for each JSON line to enhance performance.
|
||||||
|
- Added validation logic in BusinessRecord class to ensure that the mandatory fields are checked.
|
||||||
|
- Adjusted environment variable loading in OpenData class to ensure correct database initialization.
|
||||||
|
- Included missing dependencies and exports in the project files to ensure proper functionality.
|
||||||
|
|
||||||
|
## 2025-01-04 - 1.4.0 - feat(HandelsRegister)
|
||||||
|
Add file download functionality to HandelsRegister
|
||||||
|
|
||||||
|
- Implemented file download feature in the HandelsRegister class.
|
||||||
|
- Configured pages in Puppeteer to allow downloads and set download paths.
|
||||||
|
- Parsed German registration information with more robust error handling.
|
||||||
|
- Added specific methods for downloading and handling 'SI' and 'AD' files.
|
||||||
|
|
||||||
|
## 2025-01-03 - 1.3.1 - fix(HandelsRegister)
|
||||||
|
Refined HandelsRegister functionality for better error handling and response capture.
|
||||||
|
|
||||||
|
- Improved parsing logic in parseGermanRegistration function.
|
||||||
|
- Enhanced navigateToPage and clickFindButton methods with error messages for clarity.
|
||||||
|
- Implemented a new responseListener to handle and log HTTP responses correctly.
|
||||||
|
|
||||||
|
## 2025-01-03 - 1.3.0 - feat(core)
|
||||||
|
Enhanced data handling capabilities and improved company search functionalities.
|
||||||
|
|
||||||
|
- Updated business record handling to support more registration types.
|
||||||
|
- Improved search capabilities for fetching company data with refined registration type matching.
|
||||||
|
- Added robust logging for JSONL data processing with early exit on successful parse.
|
||||||
|
- Reorganized test cases to include specific company data retrieval.
|
||||||
|
|
||||||
|
## 2025-01-02 - 1.2.1 - fix(BusinessRecord)
|
||||||
|
Add missing field registrationType to BusinessRecord data
|
||||||
|
|
||||||
|
- Introduced the 'registrationType' field to the BusinessRecord data schema with possible values 'HRA' or 'HRB'.
|
||||||
|
|
||||||
|
## 2025-01-02 - 1.2.0 - feat(core)
|
||||||
|
Integrate Handelsregister search for company data retrieval
|
||||||
|
|
||||||
|
- Added support for searching company data via Handelsregister.
|
||||||
|
- Replaced GermanBusinessData functionality with JsonlDataProcessor.
|
||||||
|
- Included smartbrowser dependency for handling web requests to Handelsregister.
|
||||||
|
|
||||||
|
## 2025-01-01 - 1.1.5 - fix(GermanBusinessData)
|
||||||
|
Add console log for total records processed at the end of the stream.
|
||||||
|
|
||||||
|
- Ensure that the number of records processed is logged at the end of data stream processing.
|
||||||
|
|
||||||
|
## 2024-12-31 - 1.1.4 - fix(documentation)
|
||||||
|
Update description and keywords in package.json
|
||||||
|
|
||||||
|
- Corrected the package description to reflect the focus on managing, accessing, and updating open data with MongoDB integration.
|
||||||
|
- Expanded the keywords in the package metadata to include data integration and processing terms.
|
||||||
|
- Improved README.md with more extensive setup, usage, and introduction of the library's functionalities.
|
||||||
|
|
||||||
|
## 2024-12-31 - 1.1.3 - fix(core)
|
||||||
|
Added missing license file for project completeness.
|
||||||
|
|
||||||
|
- Introduced a LICENSE file to the project, ensuring clarity on software usage permissions.
|
||||||
|
|
||||||
## 2024-12-31 - 1.1.2 - fix(GermanBusinessData)
|
## 2024-12-31 - 1.1.2 - fix(GermanBusinessData)
|
||||||
Ensure unique ID generation for BusinessRecord
|
Ensure unique ID generation for BusinessRecord
|
||||||
|
|
||||||
|
|||||||
19
license
Normal file
19
license
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2022 Task Venture Capital GmbH (hello@task.vc)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -5,26 +5,34 @@
|
|||||||
"githost": "gitlab.com",
|
"githost": "gitlab.com",
|
||||||
"gitscope": "fin.cx",
|
"gitscope": "fin.cx",
|
||||||
"gitrepo": "opendata",
|
"gitrepo": "opendata",
|
||||||
"description": "A TypeScript-based library for accessing and managing open business data, specifically for German companies.",
|
"description": "A comprehensive TypeScript library that manages open business data for German companies by integrating MongoDB, processing JSONL bulk data, and automating browser interactions for Handelsregister data retrieval.",
|
||||||
"npmPackagename": "@fin.cx/opendata",
|
"npmPackagename": "@fin.cx/opendata",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "fin.cx",
|
"projectDomain": "fin.cx",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"TypeScript",
|
"TypeScript",
|
||||||
"open data",
|
"open data",
|
||||||
"business data",
|
|
||||||
"German companies",
|
"German companies",
|
||||||
"data management",
|
"business data",
|
||||||
"business registry",
|
|
||||||
"npm package",
|
|
||||||
"database",
|
|
||||||
"MongoDB",
|
"MongoDB",
|
||||||
"automation"
|
"JSONL",
|
||||||
|
"bulk processing",
|
||||||
|
"data management",
|
||||||
|
"automation",
|
||||||
|
"browser automation",
|
||||||
|
"Handelsregister",
|
||||||
|
"web scraping",
|
||||||
|
"file processing",
|
||||||
|
"business registry",
|
||||||
|
"data retrieval"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"npmci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"npmAccessLevel": "public"
|
"npmAccessLevel": "public"
|
||||||
|
},
|
||||||
|
"tsdoc": {
|
||||||
|
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
64
package.json
64
package.json
@@ -1,45 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "@fin.cx/opendata",
|
"name": "@fin.cx/opendata",
|
||||||
"version": "1.1.2",
|
"version": "3.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A TypeScript-based library for accessing and managing open business data, specifically for German companies.",
|
"description": "A comprehensive TypeScript library for accessing business data and real-time financial information. Features include German company data management with MongoDB integration, JSONL bulk processing, automated Handelsregister interactions, and real-time stock market data from multiple providers.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.2.0",
|
"@git.zone/tsbuild": "^2.6.8",
|
||||||
"@git.zone/tsbundle": "^2.1.0",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^1.0.90",
|
"@git.zone/tstest": "^2.4.2",
|
||||||
"@push.rocks/tapbundle": "^5.5.4",
|
"@types/node": "^22.14.0"
|
||||||
"@types/node": "^22.10.2"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartarchive": "^4.0.39",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartdata": "^5.2.10",
|
"@push.rocks/smartarchive": "^4.2.2",
|
||||||
|
"@push.rocks/smartarray": "^1.1.0",
|
||||||
|
"@push.rocks/smartbrowser": "^2.0.8",
|
||||||
|
"@push.rocks/smartdata": "^5.16.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartfile": "^11.0.23",
|
"@push.rocks/smartfile": "^11.2.7",
|
||||||
"@push.rocks/smartpath": "^5.0.18",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartpromise": "^4.0.4",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartrequest": "^2.0.23",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartstream": "^3.2.5"
|
"@push.rocks/smartrequest": "^4.3.1",
|
||||||
|
"@push.rocks/smartstream": "^3.2.5",
|
||||||
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@push.rocks/smartxml": "^1.1.1",
|
||||||
|
"@tsclass/tsclass": "^9.3.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://gitlab.com/fin.cx/opendata.git"
|
"url": "https://code.foss.global/fin.cx/opendata.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitlab.com/fin.cx/opendata/issues"
|
"url": "https://code.foss.global/fin.cx/opendata/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitlab.com/fin.cx/opendata#readme",
|
"homepage": "https://code.foss.global/fin.cx/opendata#readme",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 1 chrome versions"
|
"last 1 chrome versions"
|
||||||
],
|
],
|
||||||
@@ -58,13 +64,19 @@
|
|||||||
"keywords": [
|
"keywords": [
|
||||||
"TypeScript",
|
"TypeScript",
|
||||||
"open data",
|
"open data",
|
||||||
"business data",
|
|
||||||
"German companies",
|
"German companies",
|
||||||
"data management",
|
"business data",
|
||||||
"business registry",
|
|
||||||
"npm package",
|
|
||||||
"database",
|
|
||||||
"MongoDB",
|
"MongoDB",
|
||||||
"automation"
|
"JSONL",
|
||||||
]
|
"bulk processing",
|
||||||
|
"data management",
|
||||||
|
"automation",
|
||||||
|
"browser automation",
|
||||||
|
"Handelsregister",
|
||||||
|
"web scraping",
|
||||||
|
"file processing",
|
||||||
|
"business registry",
|
||||||
|
"data retrieval"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||||
}
|
}
|
||||||
|
|||||||
7372
pnpm-lock.yaml
generated
7372
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
# OpenData Project Hints
|
||||||
|
|
||||||
|
## Stocks Module
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
The stocks module provides real-time stock price data through various provider implementations. Currently supports Yahoo Finance with an extensible architecture for additional providers.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Provider Pattern**: Each stock data source implements the `IStockProvider` interface
|
||||||
|
- **Service Registry**: `StockPriceService` manages providers with priority-based selection
|
||||||
|
- **Caching**: Built-in cache with configurable TTL to reduce API calls
|
||||||
|
- **Fallback Logic**: Automatic failover between providers if one fails
|
||||||
|
|
||||||
|
### Yahoo Finance Provider Notes
|
||||||
|
- Uses public API endpoints (no authentication required)
|
||||||
|
- Two main endpoints:
|
||||||
|
- `/v8/finance/chart/{ticker}` - Single ticker with full data
|
||||||
|
- `/v8/finance/spark?symbols={tickers}` - Multiple tickers with basic data
|
||||||
|
- Response data is in `response.body` when using smartrequest
|
||||||
|
- Requires User-Agent header to avoid rate limiting
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
```typescript
|
||||||
|
import { StockPriceService, YahooFinanceProvider } from '@fin.cx/opendata';
|
||||||
|
|
||||||
|
const stockService = new StockPriceService({ ttl: 60000 });
|
||||||
|
const yahooProvider = new YahooFinanceProvider();
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
console.log(`${price.ticker}: $${price.price}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Tests use real API calls (be mindful of rate limits)
|
||||||
|
- Mock invalid ticker 'INVALID_TICKER_XYZ' for error testing
|
||||||
|
- Clear cache between tests to ensure fresh data
|
||||||
|
- The spark endpoint may return fewer results than requested
|
||||||
|
|
||||||
|
### Future Providers
|
||||||
|
To add a new provider:
|
||||||
|
1. Create `ts/stocks/providers/provider.{name}.ts`
|
||||||
|
2. Implement the `IStockProvider` interface
|
||||||
|
3. Register with `StockPriceService`
|
||||||
|
4. No changes needed to existing code
|
||||||
770
readme.md
770
readme.md
@@ -1,253 +1,669 @@
|
|||||||
```markdown
|
|
||||||
# @fin.cx/opendata
|
# @fin.cx/opendata
|
||||||
open business data
|
|
||||||
|
|
||||||
## Install
|
🚀 **Complete financial intelligence toolkit for TypeScript**
|
||||||
|
|
||||||
To install the `@fin.cx/opendata` package, you can use npm or yarn as your package manager. Here's how you can do it:
|
Access real-time stock prices, fundamental financial data, and comprehensive German company information - all through a single, unified API.
|
||||||
|
|
||||||
Using npm:
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @fin.cx/opendata
|
npm install @fin.cx/opendata
|
||||||
|
# or
|
||||||
|
pnpm add @fin.cx/opendata
|
||||||
```
|
```
|
||||||
|
|
||||||
Using yarn:
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
### 📈 Stock Market Data
|
||||||
yarn add @fin.cx/opendata
|
|
||||||
|
Get real-time prices with company information included automatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { StockPriceService, MarketstackProvider } from '@fin.cx/opendata';
|
||||||
|
|
||||||
|
// Initialize service with smart caching
|
||||||
|
const stockService = new StockPriceService({
|
||||||
|
ttl: 60000, // Cache TTL (historical cached forever)
|
||||||
|
maxEntries: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register provider with API key
|
||||||
|
stockService.register(new MarketstackProvider('YOUR_API_KEY'));
|
||||||
|
|
||||||
|
// Get current price with company name (zero extra API calls!)
|
||||||
|
const apple = await stockService.getData({ type: 'current', ticker: 'AAPL' });
|
||||||
|
|
||||||
|
console.log(`${apple.companyFullName}: $${apple.price}`);
|
||||||
|
// Output: "Apple Inc (NASDAQ:AAPL): $270.37"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
### 💰 Fundamental Financial Data
|
||||||
|
|
||||||
The `@fin.cx/opendata` package offers functionalities for handling open business data, with a primary focus on German business data. Let's explore its capabilities through detailed examples.
|
Access comprehensive financial metrics from SEC filings - completely FREE:
|
||||||
|
|
||||||
### Setting Up
|
```typescript
|
||||||
|
import { SecEdgarProvider, FundamentalsService } from '@fin.cx/opendata';
|
||||||
|
|
||||||
#### Importing the Module
|
// Setup SEC EDGAR provider (no API key required!)
|
||||||
|
const secEdgar = new SecEdgarProvider({
|
||||||
|
userAgent: 'YourCompany youremail@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
Begin by importing necessary components from the `@fin.cx/opendata` package. You'll also need to set up some environment variables for the MongoDB instance.
|
const fundamentalsService = new FundamentalsService();
|
||||||
|
fundamentalsService.register(secEdgar);
|
||||||
|
|
||||||
|
// Fetch fundamentals for Apple
|
||||||
|
const fundamentals = await fundamentalsService.getFundamentals('AAPL');
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
company: fundamentals.companyName, // "Apple Inc."
|
||||||
|
eps: fundamentals.earningsPerShareDiluted, // $6.13
|
||||||
|
revenue: fundamentals.revenue, // $385.6B
|
||||||
|
sharesOutstanding: fundamentals.sharesOutstanding // 15.3B
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate market cap and P/E ratio
|
||||||
|
const price = await stockService.getData({ type: 'current', ticker: 'AAPL' });
|
||||||
|
const enriched = await fundamentalsService.enrichWithPrice(fundamentals, price.price);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
marketCap: `$${(enriched.marketCap! / 1_000_000_000_000).toFixed(2)}T`,
|
||||||
|
peRatio: enriched.priceToEarnings!.toFixed(2),
|
||||||
|
pbRatio: enriched.priceToBook?.toFixed(2)
|
||||||
|
});
|
||||||
|
// Output: { marketCap: "$2.65T", peRatio: "28.42", pbRatio: "45.12" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🏢 German Business Intelligence
|
||||||
|
|
||||||
|
Access comprehensive German company data:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { OpenData } from '@fin.cx/opendata';
|
import { OpenData } from '@fin.cx/opendata';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
const startOpenDataInstance = async () => {
|
// Configure directory paths
|
||||||
const openData = new OpenData();
|
const openData = new OpenData({
|
||||||
|
nogitDir: path.join(process.cwd(), '.nogit'),
|
||||||
|
downloadDir: path.join(process.cwd(), '.nogit', 'downloads'),
|
||||||
|
germanBusinessDataDir: path.join(process.cwd(), '.nogit', 'germanbusinessdata')
|
||||||
|
});
|
||||||
|
await openData.start();
|
||||||
|
|
||||||
await openData.start(); // Start the open data instance
|
// Search for companies
|
||||||
console.log('OpenData instance started.');
|
const results = await openData.handelsregister.searchCompany("Siemens AG");
|
||||||
|
|
||||||
// your code here
|
// Get detailed information with documents
|
||||||
|
const details = await openData.handelsregister.getSpecificCompany({
|
||||||
await openData.stop();
|
court: "Munich",
|
||||||
console.log('OpenData instance stopped.');
|
type: "HRB",
|
||||||
};
|
number: "6684"
|
||||||
|
});
|
||||||
startOpenDataInstance().catch(console.error);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### BusinessRecord Usage
|
## Features
|
||||||
|
|
||||||
A `BusinessRecord` is the main entity you'll be working with. Here's how you manage business records.
|
### 📊 Stock Market Module
|
||||||
|
|
||||||
#### Creating a New BusinessRecord
|
- **Real-Time Prices** - Live and EOD stock prices from Yahoo Finance and Marketstack
|
||||||
|
- **Company Names** - Automatic company name extraction (e.g., "Apple Inc (NASDAQ:AAPL)")
|
||||||
|
- **Historical Data** - Up to 15 years of daily EOD prices with pagination
|
||||||
|
- **OHLCV Data** - Open, High, Low, Close, Volume for technical analysis
|
||||||
|
- **Exchange Filtering** - Query specific exchanges via MIC codes (XNAS, XLON, XNYS)
|
||||||
|
- **Smart Caching** - Data-type aware TTL (historical cached forever, EOD 24h, live 30s)
|
||||||
|
- **Batch Operations** - Fetch 100+ symbols in one request
|
||||||
|
- **Type-Safe API** - Full TypeScript support with discriminated unions
|
||||||
|
- **Multi-Provider** - Automatic fallback between providers
|
||||||
|
|
||||||
|
### 💰 Fundamental Data Module
|
||||||
|
|
||||||
|
- **SEC EDGAR Integration** - FREE fundamental data directly from SEC filings
|
||||||
|
- **Comprehensive Metrics** - EPS, Revenue, Assets, Liabilities, Cash Flow, and more
|
||||||
|
- **All US Public Companies** - Complete coverage of SEC-registered companies
|
||||||
|
- **Historical Filings** - Data back to ~2009
|
||||||
|
- **CIK Lookup** - Automatic ticker-to-CIK mapping with smart caching
|
||||||
|
- **Calculated Ratios** - Market Cap, P/E, P/B ratios when combined with prices
|
||||||
|
- **No API Key Required** - Direct access to SEC's public API
|
||||||
|
- **Rate Limit Management** - Built-in 10 req/sec rate limiting
|
||||||
|
|
||||||
|
### 🇩🇪 German Business Intelligence
|
||||||
|
|
||||||
|
- **MongoDB Integration** - Scalable data storage for millions of records
|
||||||
|
- **Bulk JSONL Import** - Process multi-GB datasets efficiently
|
||||||
|
- **Handelsregister Automation** - Automated document retrieval
|
||||||
|
- **CRUD Operations** - Full database management with validation
|
||||||
|
- **Streaming Processing** - Handle large datasets without memory issues
|
||||||
|
|
||||||
|
## Advanced Examples
|
||||||
|
|
||||||
|
### Combined Market Analysis
|
||||||
|
|
||||||
|
Combine price data with fundamentals for comprehensive analysis:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { BusinessRecord } from '@fin.cx/opendata';
|
import { StockPriceService, MarketstackProvider, SecEdgarProvider, FundamentalsService } from '@fin.cx/opendata';
|
||||||
|
|
||||||
const createBusinessRecord = async (openData: OpenData) => {
|
// Setup services
|
||||||
const businessRecord = new openData.CBusinessRecord();
|
const stockService = new StockPriceService({ ttl: 60000 });
|
||||||
businessRecord.data.name = "Example Company";
|
stockService.register(new MarketstackProvider('YOUR_API_KEY'));
|
||||||
businessRecord.data.address = "Example Street 1";
|
|
||||||
businessRecord.data.postalCode = "12345";
|
|
||||||
businessRecord.data.city = "Example City";
|
|
||||||
businessRecord.data.country = "Germany";
|
|
||||||
businessRecord.data.phone = "+49 123 456789";
|
|
||||||
businessRecord.data.email = "contact@example.com";
|
|
||||||
businessRecord.data.website = "https://example.com";
|
|
||||||
businessRecord.data.businessType = "GmbH";
|
|
||||||
businessRecord.data.registrationNumber = "HRB 123456";
|
|
||||||
businessRecord.data.registrationCourt = "Munich";
|
|
||||||
businessRecord.data.legalForm = "GmbH";
|
|
||||||
businessRecord.data.managingDirectors = ["John Doe", "Jane Smith"];
|
|
||||||
businessRecord.data.foundingDate = new Date().toISOString();
|
|
||||||
businessRecord.data.capital = "50,000 EUR";
|
|
||||||
businessRecord.data.purpose = "Tech Solutions";
|
|
||||||
businessRecord.data.lastUpdate = new Date().toISOString();
|
|
||||||
|
|
||||||
await businessRecord.save();
|
const fundamentalsService = new FundamentalsService();
|
||||||
console.log('BusinessRecord saved:', businessRecord);
|
fundamentalsService.register(new SecEdgarProvider({
|
||||||
};
|
userAgent: 'YourCompany youremail@example.com'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Analyze multiple companies
|
||||||
|
const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN'];
|
||||||
|
|
||||||
|
for (const ticker of tickers) {
|
||||||
|
// Get price and fundamentals in parallel
|
||||||
|
const [price, fundamentals] = await Promise.all([
|
||||||
|
stockService.getData({ type: 'current', ticker }),
|
||||||
|
fundamentalsService.getFundamentals(ticker)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const enriched = await fundamentalsService.enrichWithPrice(fundamentals, price.price);
|
||||||
|
|
||||||
|
console.log(`\n${fundamentals.companyName} (${ticker})`);
|
||||||
|
console.log(` Price: $${price.price.toFixed(2)}`);
|
||||||
|
console.log(` Market Cap: $${(enriched.marketCap! / 1e9).toFixed(2)}B`);
|
||||||
|
console.log(` P/E Ratio: ${enriched.priceToEarnings!.toFixed(2)}`);
|
||||||
|
console.log(` Revenue: $${(fundamentals.revenue! / 1e9).toFixed(2)}B`);
|
||||||
|
console.log(` EPS: $${fundamentals.earningsPerShareDiluted!.toFixed(2)}`);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Retrieving BusinessRecord
|
### Historical Data Analysis
|
||||||
|
|
||||||
Retrieve a business record by querying the database.
|
Fetch and analyze historical price trends:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { BusinessRecord } from '@fin.cx/opendata';
|
// Get 1 year of historical data
|
||||||
|
const history = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'AAPL',
|
||||||
|
from: new Date('2024-01-01'),
|
||||||
|
to: new Date('2024-12-31'),
|
||||||
|
sort: 'DESC' // Newest first
|
||||||
|
});
|
||||||
|
|
||||||
const findBusinessRecord = async (openData: OpenData) => {
|
// Calculate statistics
|
||||||
const businessRecords = await openData.db.collection<BusinessRecord>('businessrecords').find().toArray();
|
const prices = history.map(p => p.price);
|
||||||
console.log('Retrieved Business Records:', businessRecords);
|
const high52Week = Math.max(...prices);
|
||||||
};
|
const low52Week = Math.min(...prices);
|
||||||
```
|
const avgPrice = prices.reduce((a, b) => a + b) / prices.length;
|
||||||
|
|
||||||
### Updating Business Data
|
console.log(`52-Week Analysis for AAPL:`);
|
||||||
|
console.log(` High: $${high52Week.toFixed(2)}`);
|
||||||
|
console.log(` Low: $${low52Week.toFixed(2)}`);
|
||||||
|
console.log(` Average: $${avgPrice.toFixed(2)}`);
|
||||||
|
console.log(` Days: ${history.length}`);
|
||||||
|
|
||||||
The `GermanBusinessData` class handles the specifics of updating and maintaining the data.
|
// Calculate Simple Moving Average
|
||||||
|
const calculateSMA = (data: IStockPrice[], period: number) => {
|
||||||
#### Updating German Business Data
|
const sma: number[] = [];
|
||||||
|
for (let i = period - 1; i < data.length; i++) {
|
||||||
```typescript
|
const sum = data.slice(i - period + 1, i + 1)
|
||||||
import { OpenData } from '@fin.cx/opendata';
|
.reduce((acc, p) => acc + p.price, 0);
|
||||||
|
sma.push(sum / period);
|
||||||
const updateGermanBusinessData = async (openData: OpenData) => {
|
}
|
||||||
await openData.germanBusinesses.update();
|
return sma;
|
||||||
console.log('German business data updated.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
startOpenDataInstance()
|
const sma20 = calculateSMA(history, 20);
|
||||||
.then((openData) => {
|
const sma50 = calculateSMA(history, 50);
|
||||||
// Use the instance
|
|
||||||
return updateGermanBusinessData(openData);
|
console.log(`\nMoving Averages:`);
|
||||||
})
|
console.log(` 20-day SMA: $${sma20[0].toFixed(2)}`);
|
||||||
.catch(console.error);
|
console.log(` 50-day SMA: $${sma50[0].toFixed(2)}`);
|
||||||
```
|
```
|
||||||
|
|
||||||
This function downloads the latest data from the German business data source, processes it, and updates the local database.
|
### OHLCV Technical Analysis
|
||||||
|
|
||||||
### Detailed Class Structures and Methods
|
Use OHLCV data for technical indicators:
|
||||||
|
|
||||||
#### OpenData Class
|
|
||||||
|
|
||||||
The `OpenData` class is the main entry point.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { OpenData } from '@fin.cx/opendata';
|
const history = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'TSLA',
|
||||||
|
from: new Date('2024-11-01'),
|
||||||
|
to: new Date('2024-11-30')
|
||||||
|
});
|
||||||
|
|
||||||
class OpenData {
|
// Calculate daily trading range
|
||||||
db: plugins.smartdata.SmartdataDb;
|
for (const day of history) {
|
||||||
germanBusinesses: GermanBusinessData;
|
const range = day.high! - day.low!;
|
||||||
|
const rangePercent = (range / day.low!) * 100;
|
||||||
|
|
||||||
private serviceQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir);
|
console.log(`${day.timestamp.toISOString().split('T')[0]}:`);
|
||||||
|
console.log(` Open: $${day.open}`);
|
||||||
|
console.log(` High: $${day.high}`);
|
||||||
|
console.log(` Low: $${day.low}`);
|
||||||
|
console.log(` Close: $${day.price}`);
|
||||||
|
console.log(` Volume: ${day.volume?.toLocaleString()}`);
|
||||||
|
console.log(` Range: $${range.toFixed(2)} (${rangePercent.toFixed(2)}%)`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
public CBusinessRecord = plugins.smartdata.setDefaultManagerForDoc(this, BusinessRecord);
|
### Fundamental Data Screening
|
||||||
|
|
||||||
public async start() {
|
Screen stocks based on fundamental metrics:
|
||||||
// Initialize smart data DB
|
|
||||||
this.db = new plugins.smartdata.SmartdataDb({
|
```typescript
|
||||||
mongoDbUrl: await this.serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA'];
|
||||||
mongoDbName: await this.serviceQenv.getEnvVarOnDemand('MONGODB_NAME'),
|
|
||||||
mongoDbUser: await this.serviceQenv.getEnvVarOnDemand('MONGODB_USER'),
|
// Fetch fundamentals for all tickers
|
||||||
mongoDbPass: await this.serviceQenv.getEnvVarOnDemand('MONGODB_PASS'),
|
const allFundamentals = await fundamentalsService.getBatchFundamentals(tickers);
|
||||||
|
|
||||||
|
// Get current prices
|
||||||
|
const prices = await stockService.getData({
|
||||||
|
type: 'batch',
|
||||||
|
tickers: tickers
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create price map
|
||||||
|
const priceMap = new Map(prices.map(p => [p.ticker, p.price]));
|
||||||
|
|
||||||
|
// Enrich with prices
|
||||||
|
const enriched = await fundamentalsService.enrichBatchWithPrices(
|
||||||
|
allFundamentals,
|
||||||
|
priceMap
|
||||||
|
);
|
||||||
|
|
||||||
|
// Screen for value stocks (P/E < 25, P/B < 5)
|
||||||
|
const valueStocks = enriched.filter(f =>
|
||||||
|
f.priceToEarnings && f.priceToEarnings < 25 &&
|
||||||
|
f.priceToBook && f.priceToBook < 5
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Value Stocks:');
|
||||||
|
valueStocks.forEach(stock => {
|
||||||
|
console.log(`\n${stock.companyName} (${stock.ticker})`);
|
||||||
|
console.log(` P/E Ratio: ${stock.priceToEarnings!.toFixed(2)}`);
|
||||||
|
console.log(` P/B Ratio: ${stock.priceToBook!.toFixed(2)}`);
|
||||||
|
console.log(` Market Cap: $${(stock.marketCap! / 1e9).toFixed(2)}B`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Market Dashboard
|
||||||
|
|
||||||
|
Create a comprehensive market overview:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const indicators = [
|
||||||
|
{ ticker: 'AAPL', name: 'Apple' },
|
||||||
|
{ ticker: 'MSFT', name: 'Microsoft' },
|
||||||
|
{ ticker: 'GOOGL', name: 'Alphabet' },
|
||||||
|
{ ticker: 'AMZN', name: 'Amazon' },
|
||||||
|
{ ticker: 'TSLA', name: 'Tesla' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const prices = await stockService.getData({
|
||||||
|
type: 'batch',
|
||||||
|
tickers: indicators.map(i => i.ticker)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display with color-coded changes
|
||||||
|
prices.forEach(price => {
|
||||||
|
const indicator = indicators.find(i => i.ticker === price.ticker);
|
||||||
|
const arrow = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const color = price.change >= 0 ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${price.companyName!.padEnd(25)} $${price.price.toFixed(2).padStart(8)} ` +
|
||||||
|
`${color}${arrow} ${price.changePercent.toFixed(2)}%\x1b[0m`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exchange-Specific Trading
|
||||||
|
|
||||||
|
Compare prices across different exchanges:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vodafone trades on both London and NYSE
|
||||||
|
const exchanges = [
|
||||||
|
{ mic: 'XLON', name: 'London Stock Exchange' },
|
||||||
|
{ mic: 'XNYS', name: 'New York Stock Exchange' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const exchange of exchanges) {
|
||||||
|
try {
|
||||||
|
const price = await stockService.getData({
|
||||||
|
type: 'current',
|
||||||
|
ticker: 'VOD',
|
||||||
|
exchange: exchange.mic
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.init();
|
console.log(`${exchange.name}:`);
|
||||||
this.germanBusinesses = new GermanBusinessData(this);
|
console.log(` Price: ${price.price} ${price.currency}`);
|
||||||
await this.germanBusinesses.start();
|
console.log(` Volume: ${price.volume?.toLocaleString()}`);
|
||||||
}
|
console.log(` Exchange: ${price.exchangeName}`);
|
||||||
|
} catch (error) {
|
||||||
public async stop() {
|
console.log(`${exchange.name}: Not available`);
|
||||||
// Clean up resources if necessary
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### GermanBusinessData Class
|
## Configuration
|
||||||
|
|
||||||
The `GermanBusinessData` class handles the specifics of German business data.
|
### Stock Service Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stockService = new StockPriceService({
|
||||||
|
ttl: 60000, // Default cache TTL in ms
|
||||||
|
maxEntries: 10000 // Max cached entries
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marketstack - EOD data (requires API key)
|
||||||
|
stockService.register(new MarketstackProvider('YOUR_API_KEY'), {
|
||||||
|
enabled: true,
|
||||||
|
priority: 100,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yahoo Finance - Real-time data (no API key)
|
||||||
|
stockService.register(new YahooFinanceProvider(), {
|
||||||
|
enabled: true,
|
||||||
|
priority: 50
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fundamentals Service Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const fundamentalsService = new FundamentalsService({
|
||||||
|
ttl: 90 * 24 * 60 * 60 * 1000, // 90 days (quarterly refresh)
|
||||||
|
maxEntries: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEC EDGAR provider (FREE - no API key!)
|
||||||
|
fundamentalsService.register(new SecEdgarProvider({
|
||||||
|
userAgent: 'YourCompany youremail@example.com',
|
||||||
|
cikCacheTTL: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
fundamentalsCacheTTL: 90 * 24 * 60 * 60 * 1000, // 90 days
|
||||||
|
timeout: 30000
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Directory Configuration (German Business Data)
|
||||||
|
|
||||||
|
All directory paths are mandatory when using German business data features:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { OpenData } from '@fin.cx/opendata';
|
import { OpenData } from '@fin.cx/opendata';
|
||||||
import * as plugins from './plugins';
|
import * as path from 'path';
|
||||||
import * as paths from './paths';
|
|
||||||
|
|
||||||
class GermanBusinessData {
|
// Development
|
||||||
public openDataRef: OpenData;
|
const openData = new OpenData({
|
||||||
|
nogitDir: path.join(process.cwd(), '.nogit'),
|
||||||
|
downloadDir: path.join(process.cwd(), '.nogit', 'downloads'),
|
||||||
|
germanBusinessDataDir: path.join(process.cwd(), '.nogit', 'germanbusinessdata')
|
||||||
|
});
|
||||||
|
|
||||||
constructor(openDataRef: OpenData) {
|
// Production
|
||||||
this.openDataRef = openDataRef;
|
const openDataProd = new OpenData({
|
||||||
}
|
nogitDir: '/var/lib/myapp/data',
|
||||||
|
downloadDir: '/var/lib/myapp/data/downloads',
|
||||||
|
germanBusinessDataDir: '/var/lib/myapp/data/germanbusinessdata'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
public async start() {
|
### Environment Variables
|
||||||
await this.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
Set environment variables for API keys and database:
|
||||||
// Stop any ongoing processing
|
|
||||||
}
|
|
||||||
|
|
||||||
public async update() {
|
```env
|
||||||
const dataUrl = 'https://daten.offeneregister.de/de_companies_ocdata.jsonl.bz2';
|
# Marketstack API (for EOD stock data)
|
||||||
const dataExists = await plugins.smartfile.fs.isDirectory(paths.germanBusinessDataDir);
|
MARKETSTACK_COM_TOKEN=your_api_key_here
|
||||||
|
|
||||||
if (!dataExists) {
|
# MongoDB (for German business data)
|
||||||
await plugins.smartfile.fs.ensureDir(paths.germanBusinessDataDir);
|
MONGODB_URL=mongodb://localhost:27017
|
||||||
}
|
MONGODB_NAME=opendata
|
||||||
|
MONGODB_USER=myuser
|
||||||
|
MONGODB_PASS=mypass
|
||||||
|
```
|
||||||
|
|
||||||
const smartarchive = await plugins.smartarchive.SmartArchive.fromArchiveUrl(dataUrl);
|
## API Reference
|
||||||
const jsonlDataStream = await smartarchive.exportToStreamOfStreamFiles();
|
|
||||||
|
|
||||||
let totalRecordsCounter = 0;
|
### Stock Price Interfaces
|
||||||
let nextRest: string = '';
|
|
||||||
|
|
||||||
jsonlDataStream.pipe(
|
```typescript
|
||||||
new plugins.smartstream.SmartDuplex({
|
interface IStockPrice {
|
||||||
objectMode: true,
|
ticker: string;
|
||||||
writeFunction: async (chunkArg: plugins.smartfile.StreamFile, streamToolsArg) => {
|
price: number;
|
||||||
const readStream = await chunkArg.createReadStream();
|
currency: string;
|
||||||
readStream.pipe(
|
change: number;
|
||||||
new plugins.smartstream.SmartDuplex({
|
changePercent: number;
|
||||||
objectMode: true,
|
previousClose: number;
|
||||||
writeFunction: async (chunkArg: Buffer, streamToolsArg) => {
|
timestamp: Date;
|
||||||
const currentString = nextRest + chunkArg.toString();
|
provider: string;
|
||||||
|
marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
|
||||||
|
exchange?: string;
|
||||||
|
exchangeName?: string;
|
||||||
|
|
||||||
const lines = currentString.split('\n');
|
// OHLCV data
|
||||||
nextRest = lines.pop();
|
volume?: number;
|
||||||
|
open?: number;
|
||||||
|
high?: number;
|
||||||
|
low?: number;
|
||||||
|
adjusted?: boolean;
|
||||||
|
dataType: 'eod' | 'intraday' | 'live';
|
||||||
|
fetchedAt: Date;
|
||||||
|
|
||||||
for (const line of lines) {
|
// Company identification
|
||||||
let entry: any;
|
companyName?: string; // "Apple Inc"
|
||||||
try {
|
companyFullName?: string; // "Apple Inc (NASDAQ:AAPL)"
|
||||||
entry = JSON.parse(line);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing line:', err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalRecordsCounter++;
|
|
||||||
if (totalRecordsCounter % 10000 === 0) {
|
|
||||||
console.log(`${totalRecordsCounter} total records.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const businessRecord = new this.openDataRef.CBusinessRecord();
|
|
||||||
businessRecord.data.name = entry?.name;
|
|
||||||
|
|
||||||
await businessRecord.save();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
finalFunction: async (streamToolsArg) => {
|
|
||||||
if (nextRest) {
|
|
||||||
try {
|
|
||||||
JSON.parse(nextRest);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing final chunk:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Conclusion
|
### Fundamental Data Interfaces
|
||||||
|
|
||||||
This module is designed to make it easier to manage open business data, especially focusing on German business data. The examples above demonstrate the core functionalities, including starting and stopping the service, managing business records, and updating data.
|
```typescript
|
||||||
|
interface IStockFundamentals {
|
||||||
|
ticker: string;
|
||||||
|
cik: string;
|
||||||
|
companyName: string;
|
||||||
|
provider: string;
|
||||||
|
timestamp: Date;
|
||||||
|
fetchedAt: Date;
|
||||||
|
|
||||||
As you work with `@fin.cx/opendata`, you’ll discover it offers a robust and flexible approach for working with open business data seamlessly. Happy coding!
|
// Per-share metrics
|
||||||
|
earningsPerShareBasic?: number;
|
||||||
|
earningsPerShareDiluted?: number;
|
||||||
|
sharesOutstanding?: number;
|
||||||
|
|
||||||
|
// Income statement (annual USD)
|
||||||
|
revenue?: number;
|
||||||
|
netIncome?: number;
|
||||||
|
operatingIncome?: number;
|
||||||
|
grossProfit?: number;
|
||||||
|
|
||||||
|
// Balance sheet (annual USD)
|
||||||
|
assets?: number;
|
||||||
|
liabilities?: number;
|
||||||
|
stockholdersEquity?: number;
|
||||||
|
cash?: number;
|
||||||
|
propertyPlantEquipment?: number;
|
||||||
|
|
||||||
|
// Calculated metrics (requires price)
|
||||||
|
marketCap?: number; // price × sharesOutstanding
|
||||||
|
priceToEarnings?: number; // price / EPS
|
||||||
|
priceToBook?: number; // marketCap / stockholdersEquity
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
fiscalYear?: string;
|
||||||
|
fiscalQuarter?: string;
|
||||||
|
filingDate?: Date;
|
||||||
|
form?: '10-K' | '10-Q' | string;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
undefined
|
|
||||||
|
### Key Methods
|
||||||
|
|
||||||
|
**StockPriceService**
|
||||||
|
- `getData(request)` - Unified method for all stock data (current, historical, batch)
|
||||||
|
- `getPrice(request)` - Convenience method for single current price
|
||||||
|
- `getPrices(request)` - Convenience method for batch current prices
|
||||||
|
- `register(provider, config)` - Add data provider with priority and retry config
|
||||||
|
- `checkProvidersHealth()` - Test all providers and return health status
|
||||||
|
- `getProviderStats()` - Get success/error statistics for each provider
|
||||||
|
- `clearCache()` - Clear price cache
|
||||||
|
- `setCacheTTL(ttl)` - Update cache TTL dynamically
|
||||||
|
|
||||||
|
**FundamentalsService**
|
||||||
|
- `getFundamentals(ticker)` - Get fundamental data for single ticker
|
||||||
|
- `getBatchFundamentals(tickers)` - Get fundamentals for multiple tickers
|
||||||
|
- `enrichWithPrice(fundamentals, price)` - Calculate market cap, P/E, P/B ratios
|
||||||
|
- `enrichBatchWithPrices(fundamentals, priceMap)` - Batch enrich with prices
|
||||||
|
- `register(provider, config)` - Add fundamentals provider
|
||||||
|
- `checkProvidersHealth()` - Test all providers
|
||||||
|
- `getProviderStats()` - Get success/error statistics
|
||||||
|
- `clearCache()` - Clear fundamentals cache
|
||||||
|
|
||||||
|
**SecEdgarProvider**
|
||||||
|
- ✅ FREE - No API key required
|
||||||
|
- ✅ All US public companies
|
||||||
|
- ✅ Comprehensive US GAAP financial metrics
|
||||||
|
- ✅ Historical data back to ~2009
|
||||||
|
- ✅ Direct access to SEC filings (10-K, 10-Q)
|
||||||
|
- ✅ Smart caching (CIK: 30 days, Fundamentals: 90 days)
|
||||||
|
- ✅ Rate limiting (10 requests/second)
|
||||||
|
- ℹ️ Requires User-Agent header in format: "Company Name Email"
|
||||||
|
|
||||||
|
**MarketstackProvider**
|
||||||
|
- ✅ End-of-Day (EOD) stock prices
|
||||||
|
- ✅ 500,000+ tickers across 72+ exchanges worldwide
|
||||||
|
- ✅ Historical data with pagination
|
||||||
|
- ✅ Batch fetching support
|
||||||
|
- ✅ OHLCV data (Open, High, Low, Close, Volume)
|
||||||
|
- ✅ Company names included automatically
|
||||||
|
- ⚠️ Requires API key (free tier: 100 requests/month)
|
||||||
|
|
||||||
|
**YahooFinanceProvider**
|
||||||
|
- ✅ Real-time stock prices
|
||||||
|
- ✅ No API key required
|
||||||
|
- ✅ Global coverage
|
||||||
|
- ✅ Company names included
|
||||||
|
- ⚠️ Rate limits may apply
|
||||||
|
|
||||||
|
**OpenData**
|
||||||
|
- `start()` - Initialize MongoDB connection
|
||||||
|
- `buildInitialDb()` - Import bulk data
|
||||||
|
- `CBusinessRecord` - Business record class
|
||||||
|
- `handelsregister` - German registry automation
|
||||||
|
|
||||||
|
## Provider Architecture
|
||||||
|
|
||||||
|
Add custom data providers easily:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MyCustomProvider implements IStockProvider {
|
||||||
|
name = 'My Provider';
|
||||||
|
priority = 50;
|
||||||
|
requiresAuth = true;
|
||||||
|
rateLimit = { requestsPerMinute: 60 };
|
||||||
|
|
||||||
|
async fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
|
||||||
|
// Implement unified data fetching
|
||||||
|
switch (request.type) {
|
||||||
|
case 'current':
|
||||||
|
return this.fetchCurrentPrice(request);
|
||||||
|
case 'batch':
|
||||||
|
return this.fetchBatchPrices(request);
|
||||||
|
case 'historical':
|
||||||
|
return this.fetchHistoricalPrices(request);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request type`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
// Health check
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsMarket(market: string): boolean {
|
||||||
|
return ['US', 'UK', 'DE'].includes(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsTicker(ticker: string): boolean {
|
||||||
|
return /^[A-Z]{1,5}$/.test(ticker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stockService.register(new MyCustomProvider());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Batch Fetching**: Get 100+ prices in one API request
|
||||||
|
- **Smart Caching**: Data-type aware TTL (historical cached forever, EOD 24h, live 30s)
|
||||||
|
- **Rate Limit Management**: Automatic retry logic for API limits
|
||||||
|
- **Concurrent Processing**: Handle 1000+ records/second
|
||||||
|
- **Streaming**: Process GB-sized datasets without memory issues
|
||||||
|
- **Provider Fallback**: Automatic failover between data sources
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the comprehensive test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Test specific modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stock price providers
|
||||||
|
pnpm tstest test/test.marketstack.node.ts --verbose
|
||||||
|
pnpm tstest test/test.stocks.ts --verbose
|
||||||
|
|
||||||
|
# Fundamental data
|
||||||
|
pnpm tstest test/test.secedgar.provider.node.ts --verbose
|
||||||
|
pnpm tstest test/test.fundamentals.service.node.ts --verbose
|
||||||
|
|
||||||
|
# German business data
|
||||||
|
pnpm tstest test/test.handelsregister.ts --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting API Keys
|
||||||
|
|
||||||
|
### Marketstack (EOD Stock Data)
|
||||||
|
|
||||||
|
1. Visit [marketstack.com](https://marketstack.com)
|
||||||
|
2. Sign up for a free account (100 requests/month)
|
||||||
|
3. Get your API key from the dashboard
|
||||||
|
4. Set environment variable: `MARKETSTACK_COM_TOKEN=your_key_here`
|
||||||
|
|
||||||
|
### SEC EDGAR (Fundamental Data)
|
||||||
|
|
||||||
|
**No API key required!** SEC EDGAR is completely free and public. Just provide your company name and email in the User-Agent:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new SecEdgarProvider({
|
||||||
|
userAgent: 'YourCompany youremail@example.com'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
193
readme.plan.md
Normal file
193
readme.plan.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Stock Prices Module Implementation Plan
|
||||||
|
|
||||||
|
Command to reread guidelines: Read /home/philkunz/.claude/CLAUDE.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implementation of a stocks module for fetching current stock prices using various APIs. The architecture will support multiple providers, but we'll start with implementing only Yahoo Finance API. The design will make it easy to add additional providers (Alpha Vantage, IEX Cloud, etc.) in the future without changing the core architecture.
|
||||||
|
|
||||||
|
## Phase 1: Yahoo Finance Implementation
|
||||||
|
|
||||||
|
### 1.1 Research & Documentation
|
||||||
|
- [ ] Research Yahoo Finance API endpoints (no official API, using public endpoints)
|
||||||
|
- [ ] Document available data fields and formats
|
||||||
|
- [ ] Identify rate limits and restrictions
|
||||||
|
- [ ] Test endpoints manually with curl
|
||||||
|
|
||||||
|
### 1.2 Module Structure
|
||||||
|
```
|
||||||
|
ts/
|
||||||
|
├── index.ts # Main exports
|
||||||
|
├── plugins.ts # External dependencies
|
||||||
|
└── stocks/
|
||||||
|
├── index.ts # Stocks module exports
|
||||||
|
├── classes.stockservice.ts # Main StockPriceService class
|
||||||
|
├── interfaces/
|
||||||
|
│ ├── stockprice.ts # IStockPrice interface
|
||||||
|
│ └── provider.ts # IStockProvider interface (for all providers)
|
||||||
|
└── providers/
|
||||||
|
├── provider.yahoo.ts # Yahoo Finance implementation
|
||||||
|
└── (future: provider.alphavantage.ts, provider.iex.ts, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Core Interfaces
|
||||||
|
```typescript
|
||||||
|
// IStockPrice - Standardized stock price data
|
||||||
|
interface IStockPrice {
|
||||||
|
ticker: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
timestamp: Date;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IStockProvider - Provider implementation contract
|
||||||
|
interface IStockProvider {
|
||||||
|
name: string;
|
||||||
|
fetchPrice(ticker: string): Promise<IStockPrice>;
|
||||||
|
fetchPrices(tickers: string[]): Promise<IStockPrice[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Yahoo Finance Provider Implementation
|
||||||
|
- [ ] Create YahooFinanceProvider class
|
||||||
|
- [ ] Implement HTTP requests to Yahoo Finance endpoints
|
||||||
|
- [ ] Parse response data into IStockPrice format
|
||||||
|
- [ ] Handle errors and edge cases
|
||||||
|
- [ ] Add request throttling/rate limiting
|
||||||
|
|
||||||
|
### 1.5 Main Service Class
|
||||||
|
- [ ] Create StockPriceService class with provider registry
|
||||||
|
- [ ] Implement provider interface for pluggable providers
|
||||||
|
- [ ] Register Yahoo provider (with ability to add more later)
|
||||||
|
- [ ] Add method for single ticker lookup
|
||||||
|
- [ ] Add method for batch ticker lookup
|
||||||
|
- [ ] Implement error handling with graceful degradation
|
||||||
|
- [ ] Design fallback mechanism (ready for multiple providers)
|
||||||
|
|
||||||
|
## Phase 2: Core Features
|
||||||
|
|
||||||
|
### 2.1 Service Architecture
|
||||||
|
- [ ] Create provider registry pattern for managing multiple providers
|
||||||
|
- [ ] Implement provider priority and selection logic
|
||||||
|
- [ ] Design provider health check interface
|
||||||
|
- [ ] Create provider configuration system
|
||||||
|
- [ ] Implement provider discovery mechanism
|
||||||
|
- [ ] Add provider capability querying (which tickers/markets supported)
|
||||||
|
|
||||||
|
## Phase 3: Advanced Features
|
||||||
|
|
||||||
|
### 3.1 Caching System
|
||||||
|
- [ ] Design cache interface
|
||||||
|
- [ ] Implement in-memory cache with TTL
|
||||||
|
- [ ] Add cache invalidation logic
|
||||||
|
- [ ] Make cache configurable per ticker
|
||||||
|
|
||||||
|
### 3.2 Configuration
|
||||||
|
- [ ] Provider configuration (timeout, retry settings)
|
||||||
|
- [ ] Cache configuration (TTL, max entries)
|
||||||
|
- [ ] Request timeout configuration
|
||||||
|
- [ ] Error handling configuration
|
||||||
|
|
||||||
|
### 3.3 Error Handling
|
||||||
|
- [ ] Define custom error types
|
||||||
|
- [ ] Implement retry logic with exponential backoff
|
||||||
|
- [ ] Add circuit breaker pattern for failing providers
|
||||||
|
- [ ] Comprehensive error logging
|
||||||
|
|
||||||
|
## Phase 4: Testing
|
||||||
|
|
||||||
|
### 4.1 Unit Tests
|
||||||
|
- [ ] Test each provider independently
|
||||||
|
- [ ] Mock HTTP requests for predictable testing
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Test data transformation logic
|
||||||
|
|
||||||
|
### 4.2 Integration Tests
|
||||||
|
- [ ] Test with real API calls (rate limit aware)
|
||||||
|
- [ ] Test provider fallback scenarios
|
||||||
|
- [ ] Test batch operations
|
||||||
|
- [ ] Test cache behavior
|
||||||
|
|
||||||
|
### 4.3 Performance Tests
|
||||||
|
- [ ] Measure response times
|
||||||
|
- [ ] Test concurrent request handling
|
||||||
|
- [ ] Validate cache effectiveness
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Week 1: Yahoo Finance Provider**
|
||||||
|
- Research and test Yahoo endpoints
|
||||||
|
- Implement basic provider and service
|
||||||
|
- Create core interfaces
|
||||||
|
- Basic error handling
|
||||||
|
|
||||||
|
2. **Week 2: Service Architecture**
|
||||||
|
- Create extensible provider system
|
||||||
|
- Implement provider interface
|
||||||
|
- Add provider registration
|
||||||
|
|
||||||
|
3. **Week 3: Advanced Features**
|
||||||
|
- Implement caching system
|
||||||
|
- Add configuration management
|
||||||
|
- Enhance error handling
|
||||||
|
|
||||||
|
4. **Week 4: Testing & Documentation**
|
||||||
|
- Write comprehensive tests
|
||||||
|
- Create usage documentation
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- `@push.rocks/smartrequest` - HTTP requests
|
||||||
|
- `@push.rocks/smartpromise` - Promise utilities
|
||||||
|
- `@push.rocks/smartlog` - Logging
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `@git.zone/tstest` - Testing framework
|
||||||
|
- `@git.zone/tsrun` - TypeScript execution
|
||||||
|
|
||||||
|
## API Endpoints Research
|
||||||
|
|
||||||
|
### Yahoo Finance
|
||||||
|
- Base URL: `https://query1.finance.yahoo.com/v8/finance/chart/{ticker}`
|
||||||
|
- No authentication required
|
||||||
|
- Returns JSON with price data
|
||||||
|
- Rate limits unknown (need to test)
|
||||||
|
- Alternative endpoints to explore:
|
||||||
|
- `/v7/finance/quote` - Simplified quote data
|
||||||
|
- `/v10/finance/quoteSummary` - Detailed company data
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. Can fetch current stock prices for any valid ticker
|
||||||
|
2. Extensible architecture for future providers
|
||||||
|
3. Response time < 1 second for cached data
|
||||||
|
4. Response time < 3 seconds for fresh data
|
||||||
|
5. Proper error handling and recovery
|
||||||
|
6. Comprehensive test coverage (>80%)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Yahoo Finance provides free stock data without authentication
|
||||||
|
- **Architecture designed for multiple providers**: While only implementing Yahoo Finance initially, all interfaces, classes, and patterns are designed to support multiple stock data providers
|
||||||
|
- The provider registry pattern allows adding new providers without modifying existing code
|
||||||
|
- Each provider implements the same IStockProvider interface for consistency
|
||||||
|
- Future providers can be added by simply creating a new provider class and registering it
|
||||||
|
- Implement proper TypeScript types for all data structures
|
||||||
|
- Follow the project's coding standards (prefix interfaces with 'I')
|
||||||
|
- Use plugins.ts for all external dependencies
|
||||||
|
- Keep filenames lowercase
|
||||||
|
- Write tests using @git.zone/tstest with smartexpect syntax
|
||||||
|
- Focus on clean, extensible architecture for future growth
|
||||||
|
|
||||||
|
## Future Provider Addition Example
|
||||||
|
|
||||||
|
When ready to add a new provider (e.g., Alpha Vantage), the process will be:
|
||||||
|
1. Create `ts/stocks/providers/provider.alphavantage.ts`
|
||||||
|
2. Implement the `IStockProvider` interface
|
||||||
|
3. Register the provider in the StockPriceService
|
||||||
|
4. No changes needed to existing code or interfaces
|
||||||
287
test/test.fundamentals.service.node.ts
Normal file
287
test/test.fundamentals.service.node.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js';
|
||||||
|
|
||||||
|
const TEST_USER_AGENT = 'fin.cx test@fin.cx';
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Provider Registration', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should register provider', async () => {
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
const registered = service.getProvider('SEC EDGAR');
|
||||||
|
expect(registered).toBeDefined();
|
||||||
|
expect(registered?.name).toEqual('SEC EDGAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should get all providers', async () => {
|
||||||
|
const providers = service.getAllProviders();
|
||||||
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
expect(providers[0].name).toEqual('SEC EDGAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should get enabled providers', async () => {
|
||||||
|
const providers = service.getEnabledProviders();
|
||||||
|
expect(providers.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should unregister provider', async () => {
|
||||||
|
service.unregister('SEC EDGAR');
|
||||||
|
|
||||||
|
const registered = service.getProvider('SEC EDGAR');
|
||||||
|
expect(registered).toBeUndefined();
|
||||||
|
|
||||||
|
// Re-register for other tests
|
||||||
|
service.register(provider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Fetch Fundamentals', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should fetch fundamentals for single ticker', async () => {
|
||||||
|
const fundamentals = await service.getFundamentals('AAPL');
|
||||||
|
|
||||||
|
expect(fundamentals).toBeDefined();
|
||||||
|
expect(fundamentals.ticker).toEqual('AAPL');
|
||||||
|
expect(fundamentals.companyName).toEqual('Apple Inc.');
|
||||||
|
expect(fundamentals.provider).toEqual('SEC EDGAR');
|
||||||
|
expect(fundamentals.earningsPerShareDiluted).toBeGreaterThan(0);
|
||||||
|
expect(fundamentals.sharesOutstanding).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log('\n📊 Fetched via Service:');
|
||||||
|
console.log(` ${fundamentals.ticker}: ${fundamentals.companyName}`);
|
||||||
|
console.log(` EPS: $${fundamentals.earningsPerShareDiluted?.toFixed(2)}`);
|
||||||
|
console.log(` Shares: ${(fundamentals.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should fetch fundamentals for multiple tickers', async () => {
|
||||||
|
const fundamentalsList = await service.getBatchFundamentals(['AAPL', 'MSFT']);
|
||||||
|
|
||||||
|
expect(fundamentalsList).toBeInstanceOf(Array);
|
||||||
|
expect(fundamentalsList.length).toEqual(2);
|
||||||
|
|
||||||
|
const apple = fundamentalsList.find(f => f.ticker === 'AAPL');
|
||||||
|
const msft = fundamentalsList.find(f => f.ticker === 'MSFT');
|
||||||
|
|
||||||
|
expect(apple).toBeDefined();
|
||||||
|
expect(msft).toBeDefined();
|
||||||
|
expect(apple!.companyName).toEqual('Apple Inc.');
|
||||||
|
expect(msft!.companyName).toContain('Microsoft');
|
||||||
|
|
||||||
|
console.log('\n📊 Batch Fetch via Service:');
|
||||||
|
fundamentalsList.forEach(f => {
|
||||||
|
console.log(` ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Caching', async () => {
|
||||||
|
const service = new opendata.FundamentalsService({
|
||||||
|
ttl: 60000, // 60 seconds for testing
|
||||||
|
maxEntries: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should cache fundamentals data', async () => {
|
||||||
|
// Clear cache first
|
||||||
|
service.clearCache();
|
||||||
|
|
||||||
|
let stats = service.getCacheStats();
|
||||||
|
expect(stats.size).toEqual(0);
|
||||||
|
|
||||||
|
// First fetch (should hit API)
|
||||||
|
const start1 = Date.now();
|
||||||
|
await service.getFundamentals('AAPL');
|
||||||
|
const duration1 = Date.now() - start1;
|
||||||
|
|
||||||
|
stats = service.getCacheStats();
|
||||||
|
expect(stats.size).toEqual(1);
|
||||||
|
|
||||||
|
// Second fetch (should hit cache - much faster)
|
||||||
|
const start2 = Date.now();
|
||||||
|
await service.getFundamentals('AAPL');
|
||||||
|
const duration2 = Date.now() - start2;
|
||||||
|
|
||||||
|
expect(duration2).toBeLessThan(duration1);
|
||||||
|
|
||||||
|
console.log('\n⚡ Cache Performance:');
|
||||||
|
console.log(` First fetch: ${duration1}ms`);
|
||||||
|
console.log(` Cached fetch: ${duration2}ms`);
|
||||||
|
console.log(` Speedup: ${Math.round(duration1 / duration2)}x`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should respect cache TTL', async () => {
|
||||||
|
// Set very short TTL
|
||||||
|
service.setCacheTTL(100); // 100ms
|
||||||
|
|
||||||
|
// Fetch and cache
|
||||||
|
await service.getFundamentals('MSFT');
|
||||||
|
|
||||||
|
// Wait for TTL to expire
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// This should fetch again (cache expired)
|
||||||
|
const stats = service.getCacheStats();
|
||||||
|
console.log(`\n⏱️ Cache TTL: ${stats.ttl}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should clear cache', async () => {
|
||||||
|
service.clearCache();
|
||||||
|
|
||||||
|
const stats = service.getCacheStats();
|
||||||
|
expect(stats.size).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Price Enrichment', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should enrich fundamentals with price to calculate market cap', async () => {
|
||||||
|
const fundamentals = await service.getFundamentals('AAPL');
|
||||||
|
|
||||||
|
// Simulate current price
|
||||||
|
const currentPrice = 270.37;
|
||||||
|
|
||||||
|
const enriched = await service.enrichWithPrice(fundamentals, currentPrice);
|
||||||
|
|
||||||
|
expect(enriched.marketCap).toBeDefined();
|
||||||
|
expect(enriched.priceToEarnings).toBeDefined();
|
||||||
|
expect(enriched.priceToBook).toBeDefined();
|
||||||
|
|
||||||
|
expect(enriched.marketCap).toBeGreaterThan(0);
|
||||||
|
expect(enriched.priceToEarnings).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log('\n💰 Enriched with Price ($270.37):');
|
||||||
|
console.log(` Market Cap: $${(enriched.marketCap! / 1_000_000_000_000).toFixed(2)}T`);
|
||||||
|
console.log(` P/E Ratio: ${enriched.priceToEarnings!.toFixed(2)}`);
|
||||||
|
console.log(` P/B Ratio: ${enriched.priceToBook?.toFixed(2) || 'N/A'}`);
|
||||||
|
|
||||||
|
// Verify calculations
|
||||||
|
const expectedMarketCap = fundamentals.sharesOutstanding! * currentPrice;
|
||||||
|
expect(Math.abs(enriched.marketCap! - expectedMarketCap)).toBeLessThan(1); // Allow for rounding
|
||||||
|
|
||||||
|
const expectedPE = currentPrice / fundamentals.earningsPerShareDiluted!;
|
||||||
|
expect(Math.abs(enriched.priceToEarnings! - expectedPE)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should enrich batch fundamentals with prices', async () => {
|
||||||
|
const fundamentalsList = await service.getBatchFundamentals(['AAPL', 'MSFT']);
|
||||||
|
|
||||||
|
const priceMap = new Map<string, number>([
|
||||||
|
['AAPL', 270.37],
|
||||||
|
['MSFT', 425.50]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enriched = await service.enrichBatchWithPrices(fundamentalsList, priceMap);
|
||||||
|
|
||||||
|
expect(enriched.length).toEqual(2);
|
||||||
|
|
||||||
|
const apple = enriched.find(f => f.ticker === 'AAPL')!;
|
||||||
|
const msft = enriched.find(f => f.ticker === 'MSFT')!;
|
||||||
|
|
||||||
|
expect(apple.marketCap).toBeGreaterThan(0);
|
||||||
|
expect(msft.marketCap).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log('\n💰 Batch Enrichment:');
|
||||||
|
console.log(` AAPL: Market Cap $${(apple.marketCap! / 1_000_000_000_000).toFixed(2)}T, P/E ${apple.priceToEarnings!.toFixed(2)}`);
|
||||||
|
console.log(` MSFT: Market Cap $${(msft.marketCap! / 1_000_000_000_000).toFixed(2)}T, P/E ${msft.priceToEarnings!.toFixed(2)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Provider Health', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should check provider health', async () => {
|
||||||
|
const health = await service.checkProvidersHealth();
|
||||||
|
|
||||||
|
expect(health.size).toEqual(1);
|
||||||
|
expect(health.get('SEC EDGAR')).toBe(true);
|
||||||
|
|
||||||
|
console.log('\n💚 Provider Health:');
|
||||||
|
health.forEach((isHealthy, name) => {
|
||||||
|
console.log(` ${name}: ${isHealthy ? '✅ Healthy' : '❌ Unhealthy'}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Provider Statistics', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should track provider statistics', async () => {
|
||||||
|
// Make some requests
|
||||||
|
await service.getFundamentals('AAPL');
|
||||||
|
await service.getFundamentals('MSFT');
|
||||||
|
|
||||||
|
const stats = service.getProviderStats();
|
||||||
|
|
||||||
|
expect(stats.size).toEqual(1);
|
||||||
|
|
||||||
|
const secStats = stats.get('SEC EDGAR');
|
||||||
|
expect(secStats).toBeDefined();
|
||||||
|
expect(secStats!.successCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log('\n📈 Provider Stats:');
|
||||||
|
console.log(` Success Count: ${secStats!.successCount}`);
|
||||||
|
console.log(` Error Count: ${secStats!.errorCount}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('FundamentalsService - Error Handling', async () => {
|
||||||
|
const service = new opendata.FundamentalsService();
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
service.register(provider);
|
||||||
|
|
||||||
|
await tap.test('should throw error for invalid ticker', async () => {
|
||||||
|
try {
|
||||||
|
await service.getFundamentals('INVALIDTICKER123456');
|
||||||
|
throw new Error('Should have thrown error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain('CIK not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should throw error when no providers available', async () => {
|
||||||
|
const emptyService = new opendata.FundamentalsService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emptyService.getFundamentals('AAPL');
|
||||||
|
throw new Error('Should have thrown error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain('No fundamentals providers available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
54
test/test.handelsregister.ts
Normal file
54
test/test.handelsregister.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js'
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
||||||
|
|
||||||
|
// Test configuration - explicit paths required
|
||||||
|
const testNogitDir = plugins.path.join(paths.packageDir, '.nogit');
|
||||||
|
const testDownloadDir = plugins.path.join(testNogitDir, 'downloads');
|
||||||
|
const testGermanBusinessDataDir = plugins.path.join(testNogitDir, 'germanbusinessdata');
|
||||||
|
const testOutputDir = plugins.path.join(testNogitDir, 'testoutput');
|
||||||
|
|
||||||
|
let testOpenDataInstance: opendata.OpenData;
|
||||||
|
|
||||||
|
tap.test('first test', async () => {
|
||||||
|
testOpenDataInstance = new opendata.OpenData({
|
||||||
|
nogitDir: testNogitDir,
|
||||||
|
downloadDir: testDownloadDir,
|
||||||
|
germanBusinessDataDir: testGermanBusinessDataDir
|
||||||
|
});
|
||||||
|
expect(testOpenDataInstance).toBeInstanceOf(opendata.OpenData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should start the instance', async () => {
|
||||||
|
await testOpenDataInstance.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultsSearch = tap.test('should get the data for a company', async () => {
|
||||||
|
const result = await testOpenDataInstance.handelsregister.searchCompany('LADR', 20);
|
||||||
|
console.log(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get the data for a specific company', async () => {
|
||||||
|
let testCompany: BusinessRecord['data']['germanParsedRegistration'] = (await resultsSearch.testResultPromise)[0]['germanParsedRegistration'];
|
||||||
|
console.log(`trying to find specific company with:`);
|
||||||
|
console.log(testCompany);
|
||||||
|
const result = await testOpenDataInstance.handelsregister.getSpecificCompany(testCompany);
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
await Promise.all(result.files.map(async (file) => {
|
||||||
|
await file.writeToDir(testOutputDir);
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop the instance', async (toolsArg) => {
|
||||||
|
await testOpenDataInstance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
tap.start()
|
||||||
572
test/test.marketstack.node.ts
Normal file
572
test/test.marketstack.node.ts
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js';
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Test configuration - explicit paths required
|
||||||
|
const testNogitDir = plugins.path.join(paths.packageDir, '.nogit');
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testTickers = ['AAPL', 'MSFT', 'GOOGL'];
|
||||||
|
const invalidTicker = 'INVALID_TICKER_XYZ';
|
||||||
|
|
||||||
|
let stockService: opendata.StockPriceService;
|
||||||
|
let marketstackProvider: opendata.MarketstackProvider;
|
||||||
|
let testQenv: plugins.qenv.Qenv;
|
||||||
|
|
||||||
|
tap.test('should create StockPriceService instance', async () => {
|
||||||
|
stockService = new opendata.StockPriceService({
|
||||||
|
ttl: 30000, // 30 seconds cache
|
||||||
|
maxEntries: 100
|
||||||
|
});
|
||||||
|
expect(stockService).toBeInstanceOf(opendata.StockPriceService);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create MarketstackProvider instance', async () => {
|
||||||
|
try {
|
||||||
|
// Create qenv and get API key
|
||||||
|
testQenv = new plugins.qenv.Qenv(paths.packageDir, testNogitDir);
|
||||||
|
const apiKey = await testQenv.getEnvVarOnDemand('MARKETSTACK_COM_TOKEN');
|
||||||
|
|
||||||
|
marketstackProvider = new opendata.MarketstackProvider(apiKey, {
|
||||||
|
enabled: true,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 500
|
||||||
|
});
|
||||||
|
expect(marketstackProvider).toBeInstanceOf(opendata.MarketstackProvider);
|
||||||
|
expect(marketstackProvider.name).toEqual('Marketstack');
|
||||||
|
expect(marketstackProvider.requiresAuth).toEqual(true);
|
||||||
|
expect(marketstackProvider.priority).toEqual(80);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('MARKETSTACK_COM_TOKEN')) {
|
||||||
|
console.log('⚠️ MARKETSTACK_COM_TOKEN not set - skipping Marketstack tests');
|
||||||
|
tap.test('Marketstack token not available', async () => {
|
||||||
|
expect(true).toEqual(true); // Skip gracefully
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should register Marketstack provider with the service', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stockService.register(marketstackProvider);
|
||||||
|
const providers = stockService.getAllProviders();
|
||||||
|
expect(providers).toContainEqual(marketstackProvider);
|
||||||
|
expect(stockService.getProvider('Marketstack')).toEqual(marketstackProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should check provider health', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await stockService.checkProvidersHealth();
|
||||||
|
expect(health.get('Marketstack')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch single stock price', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(price).toHaveProperty('ticker');
|
||||||
|
expect(price).toHaveProperty('price');
|
||||||
|
expect(price).toHaveProperty('currency');
|
||||||
|
expect(price).toHaveProperty('change');
|
||||||
|
expect(price).toHaveProperty('changePercent');
|
||||||
|
expect(price).toHaveProperty('previousClose');
|
||||||
|
expect(price).toHaveProperty('timestamp');
|
||||||
|
expect(price).toHaveProperty('provider');
|
||||||
|
expect(price).toHaveProperty('marketState');
|
||||||
|
|
||||||
|
expect(price.ticker).toEqual('AAPL');
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Marketstack');
|
||||||
|
expect(price.timestamp).toBeInstanceOf(Date);
|
||||||
|
expect(price.marketState).toEqual('CLOSED'); // EOD data
|
||||||
|
|
||||||
|
console.log(`✓ Fetched AAPL: $${price.price} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch multiple stock prices', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: testTickers
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
expect(prices.length).toBeLessThanOrEqual(testTickers.length);
|
||||||
|
|
||||||
|
for (const price of prices) {
|
||||||
|
expect(testTickers).toContain(price.ticker);
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Marketstack');
|
||||||
|
expect(price.marketState).toEqual('CLOSED');
|
||||||
|
console.log(` ${price.ticker}: $${price.price}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should serve cached prices on subsequent requests', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First request - should hit the API
|
||||||
|
const firstRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Second request - should be served from cache
|
||||||
|
const secondRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(secondRequest.ticker).toEqual(firstRequest.ticker);
|
||||||
|
expect(secondRequest.price).toEqual(firstRequest.price);
|
||||||
|
expect(secondRequest.timestamp).toEqual(firstRequest.timestamp);
|
||||||
|
|
||||||
|
console.log('✓ Cache working correctly');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle invalid ticker gracefully', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stockService.getPrice({ ticker: invalidTicker });
|
||||||
|
throw new Error('Should have thrown an error for invalid ticker');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toInclude('Failed to fetch');
|
||||||
|
console.log('✓ Invalid ticker handled correctly');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support market checking', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(marketstackProvider.supportsMarket('US')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsMarket('UK')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsMarket('DE')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsMarket('JP')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsMarket('INVALID')).toEqual(false);
|
||||||
|
|
||||||
|
console.log('✓ Market support check working');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate ticker format', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(marketstackProvider.supportsTicker('AAPL')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsTicker('MSFT')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsTicker('BRK.B')).toEqual(true);
|
||||||
|
expect(marketstackProvider.supportsTicker('123456789012')).toEqual(false);
|
||||||
|
expect(marketstackProvider.supportsTicker('invalid@ticker')).toEqual(false);
|
||||||
|
|
||||||
|
console.log('✓ Ticker validation working');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get provider statistics', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = stockService.getProviderStats();
|
||||||
|
const marketstackStats = stats.get('Marketstack');
|
||||||
|
|
||||||
|
expect(marketstackStats).not.toEqual(undefined);
|
||||||
|
expect(marketstackStats.successCount).toBeGreaterThan(0);
|
||||||
|
expect(marketstackStats.errorCount).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
console.log(`✓ Provider stats: ${marketstackStats.successCount} successes, ${marketstackStats.errorCount} errors`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should test direct provider methods', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🔍 Testing direct provider methods:');
|
||||||
|
|
||||||
|
// Test isAvailable
|
||||||
|
const available = await marketstackProvider.isAvailable();
|
||||||
|
expect(available).toEqual(true);
|
||||||
|
console.log(' ✓ isAvailable() returned true');
|
||||||
|
|
||||||
|
// Test fetchData for single ticker
|
||||||
|
const price = await marketstackProvider.fetchData({ type: 'current', ticker: 'MSFT' }) as opendata.IStockPrice;
|
||||||
|
expect(price.ticker).toEqual('MSFT');
|
||||||
|
expect(price.provider).toEqual('Marketstack');
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
console.log(` ✓ fetchData (current) for MSFT: $${price.price}`);
|
||||||
|
|
||||||
|
// Test fetchData for batch
|
||||||
|
const prices = await marketstackProvider.fetchData({
|
||||||
|
type: 'batch',
|
||||||
|
tickers: ['AAPL', 'GOOGL']
|
||||||
|
}) as opendata.IStockPrice[];
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
console.log(` ✓ fetchData (batch) returned ${prices.length} prices`);
|
||||||
|
|
||||||
|
for (const p of prices) {
|
||||||
|
console.log(` ${p.ticker}: $${p.price}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch sample EOD data', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📊 Sample EOD Stock Data from Marketstack:');
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
|
||||||
|
const sampleTickers = [
|
||||||
|
{ ticker: 'AAPL', name: 'Apple Inc.' },
|
||||||
|
{ ticker: 'MSFT', name: 'Microsoft Corp.' },
|
||||||
|
{ ticker: 'GOOGL', name: 'Alphabet Inc.' },
|
||||||
|
{ ticker: 'AMZN', name: 'Amazon.com Inc.' },
|
||||||
|
{ ticker: 'TSLA', name: 'Tesla Inc.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prices = await marketstackProvider.fetchData({
|
||||||
|
type: 'batch',
|
||||||
|
tickers: sampleTickers.map(t => t.ticker)
|
||||||
|
}) as opendata.IStockPrice[];
|
||||||
|
|
||||||
|
const priceMap = new Map(prices.map(p => [p.ticker, p]));
|
||||||
|
|
||||||
|
for (const stock of sampleTickers) {
|
||||||
|
const price = priceMap.get(stock.ticker);
|
||||||
|
if (price) {
|
||||||
|
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${stock.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).padStart(10)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
console.log(`Provider: Marketstack (EOD Data)`);
|
||||||
|
console.log(`Last updated: ${new Date().toLocaleString()}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error fetching sample data:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(true).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should clear cache', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have something in cache
|
||||||
|
await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
stockService.clearCache();
|
||||||
|
console.log('✓ Cache cleared');
|
||||||
|
|
||||||
|
// Next request should hit the API again
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
expect(price).not.toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 1 Feature Tests
|
||||||
|
|
||||||
|
tap.test('should fetch data using new unified API (current price)', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎯 Testing Phase 1: Unified getData API');
|
||||||
|
|
||||||
|
const price = await stockService.getData({
|
||||||
|
type: 'current',
|
||||||
|
ticker: 'MSFT'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(price).not.toEqual(undefined);
|
||||||
|
expect((price as opendata.IStockPrice).ticker).toEqual('MSFT');
|
||||||
|
expect((price as opendata.IStockPrice).dataType).toEqual('eod');
|
||||||
|
expect((price as opendata.IStockPrice).fetchedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
console.log(`✓ Fetched current price: $${(price as opendata.IStockPrice).price}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch historical data with date range', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📅 Testing Phase 1: Historical Data Retrieval');
|
||||||
|
|
||||||
|
const fromDate = new Date('2024-12-01');
|
||||||
|
const toDate = new Date('2024-12-31');
|
||||||
|
|
||||||
|
const prices = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'AAPL',
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
sort: 'DESC'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
expect((prices as opendata.IStockPrice[]).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log(`✓ Fetched ${(prices as opendata.IStockPrice[]).length} historical prices`);
|
||||||
|
|
||||||
|
// Verify all data types are 'eod'
|
||||||
|
for (const price of (prices as opendata.IStockPrice[])) {
|
||||||
|
expect(price.dataType).toEqual('eod');
|
||||||
|
expect(price.ticker).toEqual('AAPL');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ All prices have correct dataType');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include OHLCV data in responses', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📊 Testing Phase 1: OHLCV Data');
|
||||||
|
|
||||||
|
const price = await stockService.getData({
|
||||||
|
type: 'current',
|
||||||
|
ticker: 'GOOGL'
|
||||||
|
});
|
||||||
|
|
||||||
|
const stockPrice = price as opendata.IStockPrice;
|
||||||
|
|
||||||
|
// Verify OHLCV fields are present
|
||||||
|
expect(stockPrice.open).not.toEqual(undefined);
|
||||||
|
expect(stockPrice.high).not.toEqual(undefined);
|
||||||
|
expect(stockPrice.low).not.toEqual(undefined);
|
||||||
|
expect(stockPrice.price).not.toEqual(undefined); // close
|
||||||
|
expect(stockPrice.volume).not.toEqual(undefined);
|
||||||
|
|
||||||
|
console.log(`✓ OHLCV Data:`);
|
||||||
|
console.log(` Open: $${stockPrice.open}`);
|
||||||
|
console.log(` High: $${stockPrice.high}`);
|
||||||
|
console.log(` Low: $${stockPrice.low}`);
|
||||||
|
console.log(` Close: $${stockPrice.price}`);
|
||||||
|
console.log(` Volume: ${stockPrice.volume?.toLocaleString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support exchange filtering', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🌍 Testing Phase 1: Exchange Filtering');
|
||||||
|
|
||||||
|
// Note: This test may fail if the exchange doesn't have data for the ticker
|
||||||
|
// In production, you'd test with tickers known to exist on specific exchanges
|
||||||
|
try {
|
||||||
|
const price = await stockService.getData({
|
||||||
|
type: 'current',
|
||||||
|
ticker: 'AAPL',
|
||||||
|
exchange: 'XNAS' // NASDAQ
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(price).not.toEqual(undefined);
|
||||||
|
console.log(`✓ Successfully filtered by exchange: ${(price as opendata.IStockPrice).exchange}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Exchange filtering test inconclusive (may need tier upgrade)');
|
||||||
|
expect(true).toEqual(true); // Don't fail test
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should verify smart caching with historical data', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n💾 Testing Phase 1: Smart Caching');
|
||||||
|
|
||||||
|
const fromDate = new Date('2024-11-01');
|
||||||
|
const toDate = new Date('2024-11-30');
|
||||||
|
|
||||||
|
// First request - should hit API
|
||||||
|
const start1 = Date.now();
|
||||||
|
const prices1 = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'TSLA',
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate
|
||||||
|
});
|
||||||
|
const duration1 = Date.now() - start1;
|
||||||
|
|
||||||
|
// Second request - should be cached (historical data cached forever)
|
||||||
|
const start2 = Date.now();
|
||||||
|
const prices2 = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'TSLA',
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate
|
||||||
|
});
|
||||||
|
const duration2 = Date.now() - start2;
|
||||||
|
|
||||||
|
expect((prices1 as opendata.IStockPrice[]).length).toEqual((prices2 as opendata.IStockPrice[]).length);
|
||||||
|
expect(duration2).toBeLessThan(duration1); // Cached should be much faster
|
||||||
|
|
||||||
|
console.log(`✓ First request: ${duration1}ms (API call)`);
|
||||||
|
console.log(`✓ Second request: ${duration2}ms (cached)`);
|
||||||
|
console.log(`✓ Speed improvement: ${Math.round((duration1 / duration2) * 10) / 10}x faster`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Company Name Feature Tests
|
||||||
|
|
||||||
|
tap.test('should include company name in single price request', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🏢 Testing Company Name Feature: Single Request');
|
||||||
|
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(price.companyName).not.toEqual(undefined);
|
||||||
|
expect(typeof price.companyName).toEqual('string');
|
||||||
|
expect(price.companyName).toInclude('Apple');
|
||||||
|
|
||||||
|
console.log(`✓ Company name retrieved: "${price.companyName}"`);
|
||||||
|
console.log(` Ticker: ${price.ticker}`);
|
||||||
|
console.log(` Price: $${price.price}`);
|
||||||
|
console.log(` Company: ${price.companyName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include company names in batch price request', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🏢 Testing Company Name Feature: Batch Request');
|
||||||
|
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: ['AAPL', 'MSFT', 'GOOGL']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log(`✓ Fetched ${prices.length} prices with company names:`);
|
||||||
|
|
||||||
|
for (const price of prices) {
|
||||||
|
expect(price.companyName).not.toEqual(undefined);
|
||||||
|
expect(typeof price.companyName).toEqual('string');
|
||||||
|
console.log(` ${price.ticker.padEnd(6)} - ${price.companyName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include company name in historical data', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🏢 Testing Company Name Feature: Historical Data');
|
||||||
|
|
||||||
|
const prices = await stockService.getData({
|
||||||
|
type: 'historical',
|
||||||
|
ticker: 'TSLA',
|
||||||
|
from: new Date('2025-10-01'),
|
||||||
|
to: new Date('2025-10-05')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
const historicalPrices = prices as opendata.IStockPrice[];
|
||||||
|
expect(historicalPrices.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// All historical records should have the same company name
|
||||||
|
for (const price of historicalPrices) {
|
||||||
|
expect(price.companyName).not.toEqual(undefined);
|
||||||
|
expect(typeof price.companyName).toEqual('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPrice = historicalPrices[0];
|
||||||
|
console.log(`✓ Historical records include company name: "${firstPrice.companyName}"`);
|
||||||
|
console.log(` Ticker: ${firstPrice.ticker}`);
|
||||||
|
console.log(` Records: ${historicalPrices.length}`);
|
||||||
|
console.log(` Date range: ${historicalPrices[historicalPrices.length - 1].timestamp.toISOString().split('T')[0]} to ${firstPrice.timestamp.toISOString().split('T')[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should verify company name is included with zero extra API calls', async () => {
|
||||||
|
if (!marketstackProvider) {
|
||||||
|
console.log('⚠️ Skipping - Marketstack provider not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n⚡ Testing Company Name Efficiency: Zero Extra API Calls');
|
||||||
|
|
||||||
|
// Clear cache to ensure we're making fresh API calls
|
||||||
|
stockService.clearCache();
|
||||||
|
|
||||||
|
// Single request timing
|
||||||
|
const start1 = Date.now();
|
||||||
|
const singlePrice = await stockService.getPrice({ ticker: 'AMZN' });
|
||||||
|
const duration1 = Date.now() - start1;
|
||||||
|
|
||||||
|
expect(singlePrice.companyName).not.toEqual(undefined);
|
||||||
|
|
||||||
|
// Batch request timing
|
||||||
|
stockService.clearCache();
|
||||||
|
const start2 = Date.now();
|
||||||
|
const batchPrices = await stockService.getPrices({ tickers: ['NVDA', 'AMD', 'INTC'] });
|
||||||
|
const duration2 = Date.now() - start2;
|
||||||
|
|
||||||
|
for (const price of batchPrices) {
|
||||||
|
expect(price.companyName).not.toEqual(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Single request (with company name): ${duration1}ms`);
|
||||||
|
console.log(`✓ Batch request (with company names): ${duration2}ms`);
|
||||||
|
console.log(`✓ Company names included in standard EOD response - zero extra calls!`);
|
||||||
|
console.log(` Single: ${singlePrice.ticker} - "${singlePrice.companyName}"`);
|
||||||
|
for (const price of batchPrices) {
|
||||||
|
console.log(` Batch: ${price.ticker} - "${price.companyName}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
261
test/test.secedgar.provider.node.ts
Normal file
261
test/test.secedgar.provider.node.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_USER_AGENT = 'fin.cx test@fin.cx';
|
||||||
|
const TEST_TICKER = 'AAPL'; // Apple Inc - well-known test case
|
||||||
|
const RATE_LIMIT_DELAY = 150; // 150ms between requests (< 10 req/sec)
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Constructor', async () => {
|
||||||
|
await tap.test('should create provider with valid User-Agent', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provider.name).toEqual('SEC EDGAR');
|
||||||
|
expect(provider.priority).toEqual(100);
|
||||||
|
expect(provider.requiresAuth).toBe(false);
|
||||||
|
expect(provider.rateLimit?.requestsPerMinute).toEqual(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should throw error if User-Agent is missing', async () => {
|
||||||
|
expect(() => {
|
||||||
|
new opendata.SecEdgarProvider({
|
||||||
|
userAgent: ''
|
||||||
|
});
|
||||||
|
}).toThrow('User-Agent is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should throw error if User-Agent format is invalid', async () => {
|
||||||
|
expect(() => {
|
||||||
|
new opendata.SecEdgarProvider({
|
||||||
|
userAgent: 'InvalidFormat'
|
||||||
|
});
|
||||||
|
}).toThrow('Invalid User-Agent format');
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should accept valid User-Agent with space and email', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: 'MyCompany contact@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provider).toBeInstanceOf(opendata.SecEdgarProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Availability', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should report as available', async () => {
|
||||||
|
const isAvailable = await provider.isAvailable();
|
||||||
|
expect(isAvailable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Fetch Fundamentals', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should fetch fundamentals for Apple (AAPL)', async () => {
|
||||||
|
const fundamentals = await provider.fetchData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker: TEST_TICKER
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify structure
|
||||||
|
expect(fundamentals).toBeDefined();
|
||||||
|
expect(fundamentals).not.toBeInstanceOf(Array);
|
||||||
|
|
||||||
|
const data = fundamentals as opendata.IStockFundamentals;
|
||||||
|
|
||||||
|
// Basic fields
|
||||||
|
expect(data.ticker).toEqual('AAPL');
|
||||||
|
expect(data.cik).toBeDefined();
|
||||||
|
expect(data.companyName).toEqual('Apple Inc.');
|
||||||
|
expect(data.provider).toEqual('SEC EDGAR');
|
||||||
|
expect(data.timestamp).toBeInstanceOf(Date);
|
||||||
|
expect(data.fetchedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
// Financial metrics (Apple should have all of these)
|
||||||
|
expect(data.earningsPerShareDiluted).toBeDefined();
|
||||||
|
expect(data.earningsPerShareDiluted).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(data.sharesOutstanding).toBeDefined();
|
||||||
|
expect(data.sharesOutstanding).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(data.revenue).toBeDefined();
|
||||||
|
expect(data.revenue).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(data.netIncome).toBeDefined();
|
||||||
|
expect(data.assets).toBeDefined();
|
||||||
|
expect(data.assets).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(data.liabilities).toBeDefined();
|
||||||
|
expect(data.stockholdersEquity).toBeDefined();
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
expect(data.fiscalYear).toBeDefined();
|
||||||
|
|
||||||
|
console.log('\n📊 Sample Apple Fundamentals:');
|
||||||
|
console.log(` Company: ${data.companyName} (CIK: ${data.cik})`);
|
||||||
|
console.log(` EPS (Diluted): $${data.earningsPerShareDiluted?.toFixed(2)}`);
|
||||||
|
console.log(` Shares Outstanding: ${(data.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
console.log(` Revenue: $${(data.revenue! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
console.log(` Net Income: $${(data.netIncome! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
console.log(` Assets: $${(data.assets! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
console.log(` Fiscal Year: ${data.fiscalYear}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should throw error for invalid ticker', async () => {
|
||||||
|
try {
|
||||||
|
await provider.fetchData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker: 'INVALIDTICKER123456'
|
||||||
|
});
|
||||||
|
throw new Error('Should have thrown error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain('CIK not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Batch Fetch', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should fetch fundamentals for multiple tickers', async () => {
|
||||||
|
const result = await provider.fetchData({
|
||||||
|
type: 'fundamentals-batch',
|
||||||
|
tickers: ['AAPL', 'MSFT']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
|
||||||
|
const fundamentalsList = result as opendata.IStockFundamentals[];
|
||||||
|
expect(fundamentalsList.length).toEqual(2);
|
||||||
|
|
||||||
|
// Check Apple
|
||||||
|
const apple = fundamentalsList.find(f => f.ticker === 'AAPL');
|
||||||
|
expect(apple).toBeDefined();
|
||||||
|
expect(apple!.companyName).toEqual('Apple Inc.');
|
||||||
|
expect(apple!.earningsPerShareDiluted).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check Microsoft
|
||||||
|
const microsoft = fundamentalsList.find(f => f.ticker === 'MSFT');
|
||||||
|
expect(microsoft).toBeDefined();
|
||||||
|
expect(microsoft!.companyName).toContain('Microsoft');
|
||||||
|
expect(microsoft!.earningsPerShareDiluted).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log('\n📊 Batch Fetch Results:');
|
||||||
|
fundamentalsList.forEach(f => {
|
||||||
|
console.log(` ${f.ticker}: ${f.companyName} - EPS: $${f.earningsPerShareDiluted?.toFixed(2)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - CIK Caching', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should cache CIK lookups', async () => {
|
||||||
|
// Clear cache first
|
||||||
|
provider.clearCache();
|
||||||
|
|
||||||
|
let stats = provider.getCacheStats();
|
||||||
|
expect(stats.cikCacheSize).toEqual(0);
|
||||||
|
|
||||||
|
// First fetch (should populate cache)
|
||||||
|
await provider.fetchData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker: 'AAPL'
|
||||||
|
});
|
||||||
|
|
||||||
|
stats = provider.getCacheStats();
|
||||||
|
expect(stats.cikCacheSize).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
console.log(`\n💾 CIK Cache: ${stats.cikCacheSize} entries`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should clear cache', async () => {
|
||||||
|
provider.clearCache();
|
||||||
|
|
||||||
|
const stats = provider.getCacheStats();
|
||||||
|
expect(stats.cikCacheSize).toEqual(0);
|
||||||
|
expect(stats.hasTickerList).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Rate Limiting', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should handle multiple rapid requests without exceeding rate limit', async () => {
|
||||||
|
// Make 5 requests in succession
|
||||||
|
// Rate limiter should ensure we don't exceed 10 req/sec
|
||||||
|
const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META'];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const promises = tickers.map(ticker =>
|
||||||
|
provider.fetchData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
expect(results.length).toEqual(5);
|
||||||
|
console.log(`\n⏱️ 5 requests completed in ${duration}ms (avg: ${Math.round(duration / 5)}ms/request)`);
|
||||||
|
|
||||||
|
// Verify all results are valid
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
const data = result as opendata.IStockFundamentals;
|
||||||
|
expect(data.ticker).toEqual(tickers[index]);
|
||||||
|
expect(data.earningsPerShareDiluted).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SEC EDGAR Provider - Market Cap Calculation', async () => {
|
||||||
|
const provider = new opendata.SecEdgarProvider({
|
||||||
|
userAgent: TEST_USER_AGENT
|
||||||
|
});
|
||||||
|
|
||||||
|
await tap.test('should provide data needed for market cap calculation', async () => {
|
||||||
|
const fundamentals = await provider.fetchData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker: 'AAPL'
|
||||||
|
}) as opendata.IStockFundamentals;
|
||||||
|
|
||||||
|
expect(fundamentals.sharesOutstanding).toBeDefined();
|
||||||
|
expect(fundamentals.earningsPerShareDiluted).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate current price (in real usage, this comes from price provider)
|
||||||
|
const simulatedPrice = 270.37;
|
||||||
|
|
||||||
|
// Calculate market cap
|
||||||
|
const marketCap = fundamentals.sharesOutstanding! * simulatedPrice;
|
||||||
|
const pe = simulatedPrice / fundamentals.earningsPerShareDiluted!;
|
||||||
|
|
||||||
|
console.log('\n💰 Calculated Metrics (with simulated price $270.37):');
|
||||||
|
console.log(` Shares Outstanding: ${(fundamentals.sharesOutstanding! / 1_000_000_000).toFixed(2)}B`);
|
||||||
|
console.log(` Market Cap: $${(marketCap / 1_000_000_000_000).toFixed(2)}T`);
|
||||||
|
console.log(` EPS: $${fundamentals.earningsPerShareDiluted!.toFixed(2)}`);
|
||||||
|
console.log(` P/E Ratio: ${pe.toFixed(2)}`);
|
||||||
|
|
||||||
|
expect(marketCap).toBeGreaterThan(0);
|
||||||
|
expect(pe).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
280
test/test.stocks.ts
Normal file
280
test/test.stocks.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as opendata from '../ts/index.js';
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testTickers = ['AAPL', 'MSFT', 'GOOGL'];
|
||||||
|
const invalidTicker = 'INVALID_TICKER_XYZ';
|
||||||
|
|
||||||
|
let stockService: opendata.StockPriceService;
|
||||||
|
let yahooProvider: opendata.YahooFinanceProvider;
|
||||||
|
|
||||||
|
tap.test('should create StockPriceService instance', async () => {
|
||||||
|
stockService = new opendata.StockPriceService({
|
||||||
|
ttl: 30000, // 30 seconds cache
|
||||||
|
maxEntries: 100
|
||||||
|
});
|
||||||
|
expect(stockService).toBeInstanceOf(opendata.StockPriceService);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create YahooFinanceProvider instance', async () => {
|
||||||
|
yahooProvider = new opendata.YahooFinanceProvider({
|
||||||
|
enabled: true,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 500
|
||||||
|
});
|
||||||
|
expect(yahooProvider).toBeInstanceOf(opendata.YahooFinanceProvider);
|
||||||
|
expect(yahooProvider.name).toEqual('Yahoo Finance');
|
||||||
|
expect(yahooProvider.requiresAuth).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should register Yahoo provider with the service', async () => {
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
const providers = stockService.getAllProviders();
|
||||||
|
expect(providers).toContainEqual(yahooProvider);
|
||||||
|
expect(stockService.getProvider('Yahoo Finance')).toEqual(yahooProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should check provider health', async () => {
|
||||||
|
const health = await stockService.checkProvidersHealth();
|
||||||
|
expect(health.get('Yahoo Finance')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch single stock price', async () => {
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(price).toHaveProperty('ticker');
|
||||||
|
expect(price).toHaveProperty('price');
|
||||||
|
expect(price).toHaveProperty('currency');
|
||||||
|
expect(price).toHaveProperty('change');
|
||||||
|
expect(price).toHaveProperty('changePercent');
|
||||||
|
expect(price).toHaveProperty('previousClose');
|
||||||
|
expect(price).toHaveProperty('timestamp');
|
||||||
|
expect(price).toHaveProperty('provider');
|
||||||
|
expect(price).toHaveProperty('marketState');
|
||||||
|
|
||||||
|
expect(price.ticker).toEqual('AAPL');
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Yahoo Finance');
|
||||||
|
expect(price.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch multiple stock prices', async () => {
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: testTickers
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prices).toBeArray();
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
expect(prices.length).toBeLessThanOrEqual(testTickers.length);
|
||||||
|
|
||||||
|
for (const price of prices) {
|
||||||
|
expect(testTickers).toContain(price.ticker);
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
expect(price.provider).toEqual('Yahoo Finance');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should serve cached prices on subsequent requests', async () => {
|
||||||
|
// First request - should hit the API
|
||||||
|
const firstRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Second request - should be served from cache
|
||||||
|
const secondRequest = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
expect(secondRequest.ticker).toEqual(firstRequest.ticker);
|
||||||
|
expect(secondRequest.price).toEqual(firstRequest.price);
|
||||||
|
expect(secondRequest.timestamp).toEqual(firstRequest.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle invalid ticker gracefully', async () => {
|
||||||
|
try {
|
||||||
|
await stockService.getPrice({ ticker: invalidTicker });
|
||||||
|
throw new Error('Should have thrown an error for invalid ticker');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toInclude('Failed to fetch price');
|
||||||
|
expect(error.message).toInclude(invalidTicker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support market checking', async () => {
|
||||||
|
expect(yahooProvider.supportsMarket('US')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('UK')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('DE')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsMarket('INVALID')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate ticker format', async () => {
|
||||||
|
expect(yahooProvider.supportsTicker('AAPL')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('MSFT')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('BRK.B')).toEqual(true);
|
||||||
|
expect(yahooProvider.supportsTicker('123456789012')).toEqual(false);
|
||||||
|
expect(yahooProvider.supportsTicker('invalid@ticker')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get provider statistics', async () => {
|
||||||
|
const stats = stockService.getProviderStats();
|
||||||
|
const yahooStats = stats.get('Yahoo Finance');
|
||||||
|
|
||||||
|
expect(yahooStats).not.toEqual(undefined);
|
||||||
|
expect(yahooStats.successCount).toBeGreaterThan(0);
|
||||||
|
expect(yahooStats.errorCount).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should clear cache', async () => {
|
||||||
|
// Ensure we have something in cache
|
||||||
|
await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
stockService.clearCache();
|
||||||
|
|
||||||
|
// Next request should hit the API again (we can't directly test this,
|
||||||
|
// but we can verify the method doesn't throw)
|
||||||
|
const price = await stockService.getPrice({ ticker: 'AAPL' });
|
||||||
|
expect(price).not.toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle provider unavailability', async () => {
|
||||||
|
// Clear cache first to ensure we don't get cached results
|
||||||
|
stockService.clearCache();
|
||||||
|
|
||||||
|
// Unregister all providers
|
||||||
|
stockService.unregister('Yahoo Finance');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a different ticker to avoid any caching
|
||||||
|
await stockService.getPrice({ ticker: 'TSLA' });
|
||||||
|
throw new Error('Should have thrown an error with no providers');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.message).toEqual('No stock price providers available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should fetch major market indicators', async () => {
|
||||||
|
// Re-register provider if needed
|
||||||
|
if (!stockService.getProvider('Yahoo Finance')) {
|
||||||
|
stockService.register(yahooProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketIndicators = [
|
||||||
|
// Indices
|
||||||
|
{ ticker: '^GSPC', name: 'S&P 500' },
|
||||||
|
{ ticker: '^IXIC', name: 'NASDAQ' },
|
||||||
|
{ ticker: '^DJI', name: 'DOW Jones' },
|
||||||
|
// Tech Stocks
|
||||||
|
{ ticker: 'AAPL', name: 'Apple' },
|
||||||
|
{ ticker: 'AMZN', name: 'Amazon' },
|
||||||
|
{ ticker: 'GOOGL', name: 'Google' },
|
||||||
|
{ ticker: 'META', name: 'Meta' },
|
||||||
|
{ ticker: 'MSFT', name: 'Microsoft' },
|
||||||
|
{ ticker: 'PLTR', name: 'Palantir' },
|
||||||
|
// Crypto
|
||||||
|
{ ticker: 'BTC-USD', name: 'Bitcoin' },
|
||||||
|
{ ticker: 'ETH-USD', name: 'Ethereum' },
|
||||||
|
{ ticker: 'ADA-USD', name: 'Cardano' },
|
||||||
|
// Forex & Commodities
|
||||||
|
{ ticker: 'EURUSD=X', name: 'EUR/USD' },
|
||||||
|
{ ticker: 'GC=F', name: 'Gold Futures' },
|
||||||
|
{ ticker: 'CL=F', name: 'Crude Oil Futures' }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('\n📊 Current Market Values:');
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
|
||||||
|
// Fetch all prices in batch for better performance
|
||||||
|
try {
|
||||||
|
const prices = await stockService.getPrices({
|
||||||
|
tickers: marketIndicators.map(i => i.ticker)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a map for easy lookup
|
||||||
|
const priceMap = new Map(prices.map(p => [p.ticker, p]));
|
||||||
|
|
||||||
|
// Check which tickers are missing and fetch them individually
|
||||||
|
const missingTickers: typeof marketIndicators = [];
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
if (!priceMap.has(indicator.ticker)) {
|
||||||
|
missingTickers.push(indicator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch missing tickers individually
|
||||||
|
if (missingTickers.length > 0) {
|
||||||
|
for (const indicator of missingTickers) {
|
||||||
|
try {
|
||||||
|
const price = await stockService.getPrice({ ticker: indicator.ticker });
|
||||||
|
priceMap.set(indicator.ticker, price);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore individual errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display all results with section headers
|
||||||
|
let lastSection = '';
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
// Add section headers
|
||||||
|
if (indicator.ticker.startsWith('^') && lastSection !== 'indices') {
|
||||||
|
console.log('\n📈 Market Indices');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'indices';
|
||||||
|
} else if (['AAPL', 'AMZN', 'GOOGL', 'META', 'MSFT', 'PLTR'].includes(indicator.ticker) && lastSection !== 'stocks') {
|
||||||
|
console.log('\n💻 Tech Stocks');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'stocks';
|
||||||
|
} else if (indicator.ticker.includes('-USD') && lastSection !== 'crypto') {
|
||||||
|
console.log('\n🪙 Cryptocurrencies');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'crypto';
|
||||||
|
} else if ((indicator.ticker.includes('=') || indicator.ticker.includes('=F')) && lastSection !== 'forex') {
|
||||||
|
console.log('\n💱 Forex & Commodities');
|
||||||
|
console.log('─'.repeat(65));
|
||||||
|
lastSection = 'forex';
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = priceMap.get(indicator.ticker);
|
||||||
|
if (price) {
|
||||||
|
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m'; // Green or Red
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${indicator.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: indicator.name.includes('coin') || indicator.name.includes('EUR') || indicator.name === 'Cardano' ? 4 : 2
|
||||||
|
}).padStart(12)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`${indicator.name.padEnd(20)} Data not available`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error fetching market data:', error);
|
||||||
|
// Fallback to individual fetches
|
||||||
|
for (const indicator of marketIndicators) {
|
||||||
|
try {
|
||||||
|
const price = await stockService.getPrice({ ticker: indicator.ticker });
|
||||||
|
const changeSymbol = price.change >= 0 ? '↑' : '↓';
|
||||||
|
const changeColor = price.change >= 0 ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${indicator.name.padEnd(20)} ${price.price.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: indicator.name.includes('coin') || indicator.name.includes('EUR') || indicator.name === 'Cardano' ? 4 : 2
|
||||||
|
}).padStart(12)} ${changeColor}${changeSymbol} ${price.change >= 0 ? '+' : ''}${price.change.toFixed(2)} (${price.changePercent >= 0 ? '+' : ''}${price.changePercent.toFixed(2)}%)${resetColor}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${indicator.name.padEnd(20)} Error fetching data`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('═'.repeat(65));
|
||||||
|
console.log(`Last updated: ${new Date().toLocaleString()}\n`);
|
||||||
|
|
||||||
|
// Test passes if we successfully fetch at least some indicators
|
||||||
|
expect(true).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
26
test/test.ts
26
test/test.ts
@@ -1,10 +1,23 @@
|
|||||||
import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as opendata from '../ts/index.js'
|
import * as opendata from '../ts/index.js'
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
import { BusinessRecord } from '../ts/classes.businessrecord.js';
|
||||||
|
|
||||||
|
// Test configuration - explicit paths required
|
||||||
|
const testNogitDir = plugins.path.join(paths.packageDir, '.nogit');
|
||||||
|
const testDownloadDir = plugins.path.join(testNogitDir, 'downloads');
|
||||||
|
const testGermanBusinessDataDir = plugins.path.join(testNogitDir, 'germanbusinessdata');
|
||||||
|
|
||||||
let testOpenDataInstance: opendata.OpenData;
|
let testOpenDataInstance: opendata.OpenData;
|
||||||
|
|
||||||
tap.test('first test', async () => {
|
tap.test('first test', async () => {
|
||||||
testOpenDataInstance = new opendata.OpenData();
|
testOpenDataInstance = new opendata.OpenData({
|
||||||
|
nogitDir: testNogitDir,
|
||||||
|
downloadDir: testDownloadDir,
|
||||||
|
germanBusinessDataDir: testGermanBusinessDataDir
|
||||||
|
});
|
||||||
expect(testOpenDataInstance).toBeInstanceOf(opendata.OpenData);
|
expect(testOpenDataInstance).toBeInstanceOf(opendata.OpenData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -12,4 +25,13 @@ tap.test('should start the instance', async () => {
|
|||||||
await testOpenDataInstance.start();
|
await testOpenDataInstance.start();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
tap.test('should build initial data', async () => {
|
||||||
|
await testOpenDataInstance.buildInitialDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop the instance', async () => {
|
||||||
|
await testOpenDataInstance.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
tap.start()
|
tap.start()
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@fin.cx/opendata',
|
name: '@fin.cx/opendata',
|
||||||
version: '1.1.2',
|
version: '3.1.0',
|
||||||
description: 'A TypeScript-based library for accessing and managing open business data, specifically for German companies.'
|
description: 'A comprehensive TypeScript library for accessing business data and real-time financial information. Features include German company data management with MongoDB integration, JSONL bulk processing, automated Handelsregister interactions, and real-time stock market data from multiple providers.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,67 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
@plugins.smartdata.Manager()
|
@plugins.smartdata.Manager()
|
||||||
export class BusinessRecord extends plugins.smartdata.SmartDataDbDoc<BusinessRecord, BusinessRecord> {
|
export class BusinessRecord extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
BusinessRecord,
|
||||||
|
BusinessRecord
|
||||||
|
> {
|
||||||
|
// STATIC
|
||||||
|
public static getByGermanParsedRegistration = async (parsedGermanRegistrationArg: BusinessRecord['data']['germanParsedRegistration']) => {
|
||||||
|
const businessRecords = await BusinessRecord.getInstance({
|
||||||
|
data: {
|
||||||
|
germanParsedRegistration: parsedGermanRegistrationArg,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return businessRecords;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// INSTANCE
|
||||||
@plugins.smartdata.unI()
|
@plugins.smartdata.unI()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
data: {
|
data: {
|
||||||
name?: string,
|
name?: string;
|
||||||
address?: string,
|
startDate?: string;
|
||||||
postalCode?: string,
|
endDate?: string;
|
||||||
city?: string,
|
status?: 'active' | 'liquidating' | 'closed';
|
||||||
country?: string,
|
address?: string;
|
||||||
phone?: string,
|
postalCode?: string;
|
||||||
fax?: string,
|
city?: string;
|
||||||
email?: string,
|
country?: string;
|
||||||
website?: string,
|
phone?: string;
|
||||||
businessType?: string,
|
fax?: string;
|
||||||
registrationNumber?: string,
|
email?: string;
|
||||||
registrationCourt?: string,
|
website?: string;
|
||||||
legalForm?: string,
|
businessType?: string;
|
||||||
managingDirectors?: string[],
|
registrationId?: string;
|
||||||
boardOfDirectors?: string[],
|
germanParsedRegistration?: {
|
||||||
supervisoryBoard?: string[],
|
court?: string;
|
||||||
foundingDate?: string,
|
type?: 'HRA' | 'HRB' | 'GnR' | 'PR' | 'VR' | 'GsR';
|
||||||
capital?: string,
|
number?: string;
|
||||||
purpose?: string,
|
};
|
||||||
lastUpdate?: string
|
legalForm?:
|
||||||
|
| 'GmbH'
|
||||||
|
| 'GmbH & Co. KG'
|
||||||
|
| 'AG'
|
||||||
|
| 'LLC'
|
||||||
|
| 'LLP'
|
||||||
|
| 'GmbH & Co. KGaA'
|
||||||
|
| 'GmbH & Co. KGaA, LLC';
|
||||||
|
managingDirectors?: string[];
|
||||||
|
boardOfDirectors?: string[];
|
||||||
|
supervisoryBoard?: string[];
|
||||||
|
foundingDate?: string;
|
||||||
|
capital?: string;
|
||||||
|
purpose?: string;
|
||||||
|
lastUpdate?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* validates the record against the Handelregister.
|
||||||
|
*/
|
||||||
|
public async validate() {
|
||||||
|
if (!this.data.name) throw new Error('Name is required.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import * as paths from './paths.js';
|
|
||||||
import type { OpenData } from './classes.main.opendata.js';
|
|
||||||
|
|
||||||
export class GermanBusinessData {
|
|
||||||
public openDataRef: OpenData;
|
|
||||||
constructor(openDataRefArg: OpenData) {
|
|
||||||
this.openDataRef = openDataRefArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
await this.update();
|
|
||||||
}
|
|
||||||
public async stop() {}
|
|
||||||
|
|
||||||
public async update() {
|
|
||||||
const done = plugins.smartpromise.defer();
|
|
||||||
const promiseArray: Promise<any>[] = [];
|
|
||||||
const dataUrl = 'https://daten.offeneregister.de/de_companies_ocdata.jsonl.bz2';
|
|
||||||
const dataExists = await plugins.smartfile.fs.isDirectory(paths.germanBusinessDataDir);
|
|
||||||
if (!dataExists) {
|
|
||||||
await plugins.smartfile.fs.ensureDir(paths.germanBusinessDataDir);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
|
|
||||||
const smartarchive = await plugins.smartarchive.SmartArchive.fromArchiveUrl(dataUrl);
|
|
||||||
promiseArray
|
|
||||||
.push
|
|
||||||
// smartarchive.exportToFs(paths.germanBusinessDataDir, 'de_companies_ocdata.jsonl')
|
|
||||||
();
|
|
||||||
const jsonlDataStream = await smartarchive.exportToStreamOfStreamFiles();
|
|
||||||
let totalRecordsCounter = 0;
|
|
||||||
let nextRest: string = '';
|
|
||||||
jsonlDataStream.pipe(
|
|
||||||
new plugins.smartstream.SmartDuplex({
|
|
||||||
objectMode: true,
|
|
||||||
writeFunction: async (chunkArg: plugins.smartfile.StreamFile, streamToolsArg) => {
|
|
||||||
const readStream = await chunkArg.createReadStream();
|
|
||||||
readStream.pipe(
|
|
||||||
new plugins.smartstream.SmartDuplex({
|
|
||||||
objectMode: true,
|
|
||||||
writeFunction: async (chunkArg: Buffer, streamToolsArg) => {
|
|
||||||
const currentString = nextRest + chunkArg.toString();
|
|
||||||
const lines = currentString.split('\n');
|
|
||||||
nextRest = lines.pop();
|
|
||||||
console.log(`Got another ${lines.length} records.`);
|
|
||||||
for (const line of lines) {
|
|
||||||
let entry: any;
|
|
||||||
if (!line) continue;
|
|
||||||
try {
|
|
||||||
entry = JSON.parse(line);
|
|
||||||
} catch (err) {
|
|
||||||
console.log(line);
|
|
||||||
await plugins.smartdelay.delayFor(10000);
|
|
||||||
}
|
|
||||||
if (!entry) continue;
|
|
||||||
totalRecordsCounter++;
|
|
||||||
if (totalRecordsCounter % 10000 === 0) console.log(`${totalRecordsCounter} total records.`);
|
|
||||||
const businessRecord = new this.openDataRef.CBusinessRecord();
|
|
||||||
businessRecord.id = await this.openDataRef.CBusinessRecord.getNewId();
|
|
||||||
businessRecord.data.name = entry.name;
|
|
||||||
await businessRecord.save();
|
|
||||||
// console.log(`stored ${businessRecord.data.name}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
finalFunction: async (streamToolsArg) => {
|
|
||||||
if (!nextRest) return;
|
|
||||||
JSON.parse(nextRest);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getBusinessRecordByName(nameArg: string) {
|
|
||||||
const businessRecord = await this.openDataRef.CBusinessRecord.getInstance({
|
|
||||||
data: {
|
|
||||||
name: { $regex: `${nameArg}`, $options: "i" } as any,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return businessRecord;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
360
ts/classes.handelsregister.ts
Normal file
360
ts/classes.handelsregister.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import type { BusinessRecord } from './classes.businessrecord.js';
|
||||||
|
import type { OpenData } from './classes.main.opendata.js';
|
||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the HandlesRegister exposed as a class
|
||||||
|
*/
|
||||||
|
export class HandelsRegister {
|
||||||
|
private openDataRef: OpenData;
|
||||||
|
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
||||||
|
private downloadDir: string;
|
||||||
|
private uniqueDowloadFolder: string;
|
||||||
|
|
||||||
|
// Puppeteer wrapper instance
|
||||||
|
public smartbrowserInstance = new plugins.smartbrowser.SmartBrowser();
|
||||||
|
|
||||||
|
constructor(openDataRef: OpenData, downloadDirArg: string) {
|
||||||
|
this.openDataRef = openDataRef;
|
||||||
|
this.downloadDir = downloadDirArg;
|
||||||
|
this.uniqueDowloadFolder = plugins.path.join(this.downloadDir, plugins.smartunique.uniSimple());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
// Start the browser
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.uniqueDowloadFolder);
|
||||||
|
await this.smartbrowserInstance.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
// Stop the browser
|
||||||
|
await plugins.smartfile.fs.remove(this.uniqueDowloadFolder);
|
||||||
|
await this.smartbrowserInstance.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new page and configures it to allow file downloads
|
||||||
|
* to a predefined path.
|
||||||
|
*/
|
||||||
|
public getNewPage = async () => {
|
||||||
|
const page = await this.smartbrowserInstance.headlessBrowser.newPage();
|
||||||
|
|
||||||
|
// 1) Create a DevTools session for this page
|
||||||
|
const cdpSession = await page.target().createCDPSession();
|
||||||
|
|
||||||
|
// 2) Allow file downloads and set the download path
|
||||||
|
await cdpSession.send('Page.setDownloadBehavior', {
|
||||||
|
behavior: 'allow',
|
||||||
|
downloadPath: this.uniqueDowloadFolder, // <-- Change this to your desired absolute path
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally set viewport and go to page
|
||||||
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
await page.goto('https://www.handelsregister.de/');
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
|
||||||
|
private navigateToPage = async (
|
||||||
|
pageArg: plugins.smartbrowser.smartpuppeteer.puppeteer.Page,
|
||||||
|
pageNameArg: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await pageArg.evaluate((pageNameArg2) => {
|
||||||
|
const elements = Array.from(document.querySelectorAll('.ui-menuitem-text > span'));
|
||||||
|
const targetElement = elements.find((el) => el.textContent?.trim() === pageNameArg2);
|
||||||
|
if (targetElement) {
|
||||||
|
(targetElement as HTMLElement).click();
|
||||||
|
}
|
||||||
|
}, pageNameArg);
|
||||||
|
console.log(`Navigated to the ${pageNameArg} page successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to navigate to the ${pageNameArg} page:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private waitForResults = async (pageArg: plugins.smartbrowser.smartpuppeteer.puppeteer.Page) => {
|
||||||
|
await pageArg
|
||||||
|
.waitForSelector('#ergebnissForm\\:selectedSuchErgebnisFormTable_data', {
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
await pageArg.screenshot({ path: this.downloadDir + '/error.png' });
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const businessRecords: BusinessRecord['data'][] = await pageArg.evaluate(() => {
|
||||||
|
const rows = document.querySelectorAll(
|
||||||
|
'#ergebnissForm\\:selectedSuchErgebnisFormTable_data > tr'
|
||||||
|
);
|
||||||
|
const records: BusinessRecord['data'][] = [];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const nameElement = row.querySelector('td.ui-panelgrid-cell span.marginLeft20');
|
||||||
|
const cityElement = row.querySelector('td.ui-panelgrid-cell.sitzSuchErgebnisse span');
|
||||||
|
const statusElement = row.querySelector('td.ui-panelgrid-cell span.verticalText');
|
||||||
|
const registrationCourtElement = row.querySelector(
|
||||||
|
'td.ui-panelgrid-cell.fontTableNameSize'
|
||||||
|
);
|
||||||
|
|
||||||
|
const name = nameElement?.textContent?.trim();
|
||||||
|
const city = cityElement?.textContent?.trim();
|
||||||
|
const status = statusElement?.textContent?.trim();
|
||||||
|
const registrationId = registrationCourtElement?.textContent?.trim();
|
||||||
|
|
||||||
|
// Push parsed data into records array
|
||||||
|
records.push({
|
||||||
|
name,
|
||||||
|
city,
|
||||||
|
registrationId,
|
||||||
|
businessType: status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return records;
|
||||||
|
});
|
||||||
|
return businessRecords;
|
||||||
|
};
|
||||||
|
|
||||||
|
private clickFindButton = async (pageArg: plugins.smartbrowser.smartpuppeteer.puppeteer.Page, resultsLimitArg: number = 100) => {
|
||||||
|
try {
|
||||||
|
// Wait for the button with the text "Find" to appear
|
||||||
|
await pageArg.waitForSelector('span.ui-button-text.ui-c', { timeout: 5000 });
|
||||||
|
|
||||||
|
// adjust to 100 results per page
|
||||||
|
await pageArg.select('#form\\:ergebnisseProSeite_input', `${resultsLimitArg}`);
|
||||||
|
|
||||||
|
// Locate and click the button using its text
|
||||||
|
await pageArg.evaluate(() => {
|
||||||
|
const buttons = Array.from(document.querySelectorAll('span.ui-button-text.ui-c'));
|
||||||
|
const targetButton = buttons.find((button) => button.textContent?.trim() === 'Find');
|
||||||
|
if (targetButton) {
|
||||||
|
const parentButton = targetButton.closest('button') || targetButton;
|
||||||
|
(parentButton as HTMLElement).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Find button clicked successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to find or click the "Find" button:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private async downloadFile(
|
||||||
|
pageArg: plugins.smartbrowser.smartpuppeteer.puppeteer.Page,
|
||||||
|
typeArg: 'SI' | 'AD'
|
||||||
|
) {
|
||||||
|
// Trigger the file download by clicking on the relevant link
|
||||||
|
await pageArg.evaluate((typeArg2) => {
|
||||||
|
// Locate the table body
|
||||||
|
const tableBody = document.querySelector(
|
||||||
|
'#ergebnissForm\\:selectedSuchErgebnisFormTable_data'
|
||||||
|
);
|
||||||
|
if (!tableBody) {
|
||||||
|
throw new Error('Table body not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the first row
|
||||||
|
const firstRow = tableBody.querySelector('tr:nth-child(1)');
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('First row not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the last cell in the first row
|
||||||
|
const lastCell = firstRow.querySelector('td:last-child');
|
||||||
|
if (!lastCell) {
|
||||||
|
throw new Error('Last cell not found in the first row');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the download links
|
||||||
|
const adLink = lastCell.querySelector('a:first-of-type');
|
||||||
|
const siLink = lastCell.querySelector('a:last-of-type');
|
||||||
|
if (!siLink) {
|
||||||
|
throw new Error('SI link not found in the last cell');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a click on the last <a> element
|
||||||
|
switch (typeArg2) {
|
||||||
|
case 'AD':
|
||||||
|
(adLink as HTMLElement).click();
|
||||||
|
break;
|
||||||
|
case 'SI':
|
||||||
|
(siLink as HTMLElement).click();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid file type');
|
||||||
|
}
|
||||||
|
}, typeArg);
|
||||||
|
|
||||||
|
|
||||||
|
await plugins.smartfile.fs.waitForFileToBeReady(this.uniqueDowloadFolder);
|
||||||
|
|
||||||
|
const files = await plugins.smartfile.fs.fileTreeToObject(this.uniqueDowloadFolder, '**/*');
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// lets clear the folder for the next download
|
||||||
|
await plugins.smartfile.fs.ensureEmptyDir(this.uniqueDowloadFolder);
|
||||||
|
switch (typeArg) {
|
||||||
|
case 'AD':
|
||||||
|
await file.rename(`ad.pdf`);
|
||||||
|
break;
|
||||||
|
case 'SI':
|
||||||
|
await file.rename(`si.xml`);
|
||||||
|
break;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to parse the German registration string
|
||||||
|
*/
|
||||||
|
private async parseGermanRegistration(
|
||||||
|
input: string
|
||||||
|
): Promise<BusinessRecord['data']['germanParsedRegistration']> {
|
||||||
|
// e.g. District court Berlin (Charlottenburg) HRB 123456
|
||||||
|
const regex =
|
||||||
|
/District court (\p{L}[\p{L}\s-]*?(?:\s*\([\p{L}\s-]+\))?)\s+(HRA|HRB|GnR|VR|PR|GsR)\s+(\d+)/u;
|
||||||
|
const match = input.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
court: match[1],
|
||||||
|
type: match[2] as 'HRA' | 'HRB', // Adjust if needed
|
||||||
|
number: match[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a company by name and return basic info
|
||||||
|
*/
|
||||||
|
public async searchCompany(companyNameArg: string, resultsLimitArg: number = 100) {
|
||||||
|
return this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
|
const page = await this.getNewPage();
|
||||||
|
await this.navigateToPage(page, 'Normal search');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the textarea to appear
|
||||||
|
await page.waitForSelector('#form\\:schlagwoerter', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Enter text into the textarea
|
||||||
|
const inputText = companyNameArg;
|
||||||
|
await page.evaluate((text) => {
|
||||||
|
const textarea = document.querySelector<HTMLTextAreaElement>('#form\\:schlagwoerter');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = text; // Set the value
|
||||||
|
// Trigger the change event manually if required
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
textarea.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}, inputText);
|
||||||
|
|
||||||
|
console.log('Text entered successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to find or enter text into the textarea:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the radio button's label to appear
|
||||||
|
await page.waitForSelector('label[for="form:schlagwortOptionen:0"]', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the label to select the radio button
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const label = document.querySelector<HTMLLabelElement>(
|
||||||
|
'label[for="form:schlagwortOptionen:0"]'
|
||||||
|
);
|
||||||
|
if (label) {
|
||||||
|
label.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Radio button clicked successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to find or click the radio button:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.clickFindButton(page, resultsLimitArg);
|
||||||
|
|
||||||
|
const businessRecords = await this.waitForResults(page);
|
||||||
|
|
||||||
|
// Parse out the registration info
|
||||||
|
for (const record of businessRecords) {
|
||||||
|
if (record.registrationId) {
|
||||||
|
record.germanParsedRegistration = await this.parseGermanRegistration(
|
||||||
|
record.registrationId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
return businessRecords;
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a specific company (known register type/number/court),
|
||||||
|
* then click on an element that triggers a file download.
|
||||||
|
*/
|
||||||
|
public async getSpecificCompany(companyArg: BusinessRecord['data']['germanParsedRegistration']) {
|
||||||
|
return this.asyncExecutionStack.getExclusiveExecutionSlot(async () => {
|
||||||
|
const page = await this.getNewPage();
|
||||||
|
await this.navigateToPage(page, 'Normal search');
|
||||||
|
await page.waitForSelector('#form\\:schlagwoerter', { timeout: 5000 });
|
||||||
|
|
||||||
|
// 1) Type of Register (e.g. HRB, HRA, etc.)
|
||||||
|
await page.waitForSelector('#form\\:registerArt_label');
|
||||||
|
await page.click('#form\\:registerArt_label');
|
||||||
|
await page.waitForSelector('#form\\:registerArt_items');
|
||||||
|
await page.evaluate((type) => {
|
||||||
|
const options = Array.from(document.querySelectorAll('#form\\:registerArt_items li'));
|
||||||
|
const targetOption = options.find((option) => option.textContent?.trim() === type);
|
||||||
|
(targetOption as HTMLElement)?.click();
|
||||||
|
}, companyArg.type);
|
||||||
|
|
||||||
|
// 2) Register number
|
||||||
|
await page.waitForSelector('#form\\:registerNummer');
|
||||||
|
await page.type('#form\\:registerNummer', companyArg.number);
|
||||||
|
|
||||||
|
// 3) Register court
|
||||||
|
await page.waitForSelector('#form\\:registergericht_label');
|
||||||
|
await page.click('#form\\:registergericht_label');
|
||||||
|
await page.waitForSelector('#form\\:registergericht_items');
|
||||||
|
await page.evaluate((court) => {
|
||||||
|
const options = Array.from(document.querySelectorAll('#form\\:registergericht_items li'));
|
||||||
|
const targetOption = options.find((option) => option.textContent?.trim() === court);
|
||||||
|
(targetOption as HTMLElement)?.click();
|
||||||
|
}, companyArg.court);
|
||||||
|
|
||||||
|
// Click 'Find'
|
||||||
|
await this.clickFindButton(page);
|
||||||
|
|
||||||
|
// Optionally grab the results, just for logging
|
||||||
|
const businessRecords = await this.waitForResults(page);
|
||||||
|
console.log(businessRecords);
|
||||||
|
|
||||||
|
const files: plugins.smartfile.SmartFile[] = [];
|
||||||
|
|
||||||
|
// download files
|
||||||
|
files.push(await this.downloadFile(page, 'SI'));
|
||||||
|
files.push(await this.downloadFile(page, 'AD'));
|
||||||
|
|
||||||
|
// At this point, the file should have been downloaded automatically
|
||||||
|
// to the path specified by `Page.setDownloadBehavior`
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessRecords,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get specific company by full name
|
||||||
|
*/
|
||||||
|
public async getSpecificCompanyByName(companyNameArg: string) {
|
||||||
|
const businessRecords = await this.searchCompany(companyNameArg, 1);
|
||||||
|
const result = this.getSpecificCompany(businessRecords[0].germanParsedRegistration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ts/classes.jsonldata.ts
Normal file
113
ts/classes.jsonldata.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { OpenData } from './classes.main.opendata.js';
|
||||||
|
|
||||||
|
export type SeedEntryType = {
|
||||||
|
all_attributes: {
|
||||||
|
_registerArt: string;
|
||||||
|
_registerNummer: string;
|
||||||
|
additional_data: {
|
||||||
|
AD: boolean;
|
||||||
|
CD: boolean;
|
||||||
|
DK: boolean;
|
||||||
|
HD: boolean;
|
||||||
|
SI: boolean;
|
||||||
|
UT: boolean;
|
||||||
|
VÖ: boolean;
|
||||||
|
};
|
||||||
|
federal_state: string;
|
||||||
|
native_company_number: string;
|
||||||
|
registered_office: string;
|
||||||
|
registrar: string;
|
||||||
|
};
|
||||||
|
company_number: string;
|
||||||
|
current_status: string;
|
||||||
|
jurisdiction_code: string;
|
||||||
|
name: string;
|
||||||
|
officers: {
|
||||||
|
name: string;
|
||||||
|
other_attributes: {
|
||||||
|
city: string;
|
||||||
|
firstname: string;
|
||||||
|
flag: string;
|
||||||
|
lastname: string;
|
||||||
|
};
|
||||||
|
position: string;
|
||||||
|
start_date: string; // ISO 8601 date string
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
registered_address: string;
|
||||||
|
retrieved_at: string; // ISO 8601 date string
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JsonlDataProcessor<T> {
|
||||||
|
private germanBusinessDataDir: string;
|
||||||
|
public forEachFunction: (entryArg: T) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(germanBusinessDataDirArg: string, forEachFunctionArg: typeof this.forEachFunction) {
|
||||||
|
this.germanBusinessDataDir = germanBusinessDataDirArg;
|
||||||
|
this.forEachFunction = forEachFunctionArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: define a mapper as argument instead of hard-coding it
|
||||||
|
public async processDataFromUrl(
|
||||||
|
dataUrlArg = 'https://daten.offeneregister.de/de_companies_ocdata.jsonl.bz2'
|
||||||
|
) {
|
||||||
|
const done = plugins.smartpromise.defer();
|
||||||
|
const dataExists = await plugins.smartfile.fs.isDirectory(this.germanBusinessDataDir);
|
||||||
|
if (!dataExists) {
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.germanBusinessDataDir);
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartarchive = await plugins.smartarchive.SmartArchive.fromArchiveUrl(dataUrlArg);
|
||||||
|
const jsonlDataStream = await smartarchive.exportToStreamOfStreamFiles();
|
||||||
|
let totalRecordsCounter = 0;
|
||||||
|
let nextRest: string = '';
|
||||||
|
jsonlDataStream.pipe(
|
||||||
|
new plugins.smartstream.SmartDuplex({
|
||||||
|
objectMode: true,
|
||||||
|
writeFunction: async (chunkArg: plugins.smartfile.StreamFile, streamToolsArg) => {
|
||||||
|
const readStream = await chunkArg.createReadStream();
|
||||||
|
readStream.pipe(
|
||||||
|
new plugins.smartstream.SmartDuplex({
|
||||||
|
objectMode: true,
|
||||||
|
writeFunction: async (chunkArg: Buffer, streamToolsArg) => {
|
||||||
|
const currentString = nextRest + chunkArg.toString();
|
||||||
|
const lines = currentString.split('\n');
|
||||||
|
nextRest = lines.pop();
|
||||||
|
console.log(`Got another ${lines.length} records.`);
|
||||||
|
const concurrentProcessor = new plugins.smartarray.ConcurrentProcessor<string>(
|
||||||
|
async (line) => {
|
||||||
|
let entry: T;
|
||||||
|
if (!line) return;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(line);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(line);
|
||||||
|
await plugins.smartdelay.delayFor(10000);
|
||||||
|
}
|
||||||
|
if (!entry) return;
|
||||||
|
totalRecordsCounter++;
|
||||||
|
if (totalRecordsCounter % 10000 === 0)
|
||||||
|
console.log(`${totalRecordsCounter} total records.`);
|
||||||
|
await this.forEachFunction(entry);
|
||||||
|
},
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
await concurrentProcessor.process(lines);
|
||||||
|
},
|
||||||
|
finalFunction: async (streamToolsArg) => {
|
||||||
|
console.log(`finished processing ${totalRecordsCounter} records.`);
|
||||||
|
if (nextRest) {
|
||||||
|
JSON.parse(nextRest);
|
||||||
|
};
|
||||||
|
done.resolve();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await done.promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,42 @@
|
|||||||
import { BusinessRecord } from './classes.businessrecord.js';
|
import { BusinessRecord } from './classes.businessrecord.js';
|
||||||
import { GermanBusinessData } from './classes.germanbusinessdata.js';
|
import { HandelsRegister } from './classes.handelsregister.js';
|
||||||
|
import { JsonlDataProcessor, type SeedEntryType } from './classes.jsonldata.js';
|
||||||
import * as paths from './paths.js';
|
import * as paths from './paths.js';
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
export interface IOpenDataConfig {
|
||||||
|
downloadDir: string;
|
||||||
|
germanBusinessDataDir: string;
|
||||||
|
nogitDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenData {
|
export class OpenData {
|
||||||
db: plugins.smartdata.SmartdataDb;
|
public db: plugins.smartdata.SmartdataDb;
|
||||||
germanBusinesses: GermanBusinessData;
|
private serviceQenv: plugins.qenv.Qenv;
|
||||||
private serviceQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir);
|
private config: IOpenDataConfig;
|
||||||
|
|
||||||
|
public jsonLDataProcessor: JsonlDataProcessor<SeedEntryType>;
|
||||||
|
public handelsregister: HandelsRegister;
|
||||||
|
|
||||||
public CBusinessRecord = plugins.smartdata.setDefaultManagerForDoc(this, BusinessRecord);
|
public CBusinessRecord = plugins.smartdata.setDefaultManagerForDoc(this, BusinessRecord);
|
||||||
|
|
||||||
|
constructor(configArg: IOpenDataConfig) {
|
||||||
|
if (!configArg) {
|
||||||
|
throw new Error('@fin.cx/opendata: Configuration is required. You must provide downloadDir, germanBusinessDataDir, and nogitDir paths.');
|
||||||
|
}
|
||||||
|
if (!configArg.downloadDir || !configArg.germanBusinessDataDir || !configArg.nogitDir) {
|
||||||
|
throw new Error('@fin.cx/opendata: All directory paths are required (downloadDir, germanBusinessDataDir, nogitDir).');
|
||||||
|
}
|
||||||
|
this.config = configArg;
|
||||||
|
this.serviceQenv = new plugins.qenv.Qenv(paths.packageDir, this.config.nogitDir);
|
||||||
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
|
// Ensure configured directories exist
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.config.nogitDir);
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.config.downloadDir);
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.config.germanBusinessDataDir);
|
||||||
|
|
||||||
this.db = new plugins.smartdata.SmartdataDb({
|
this.db = new plugins.smartdata.SmartdataDb({
|
||||||
mongoDbUrl: await this.serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
mongoDbUrl: await this.serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||||
mongoDbName: await this.serviceQenv.getEnvVarOnDemand('MONGODB_NAME'),
|
mongoDbName: await this.serviceQenv.getEnvVarOnDemand('MONGODB_NAME'),
|
||||||
@@ -18,8 +44,44 @@ export class OpenData {
|
|||||||
mongoDbPass: await this.serviceQenv.getEnvVarOnDemand('MONGODB_PASS'),
|
mongoDbPass: await this.serviceQenv.getEnvVarOnDemand('MONGODB_PASS'),
|
||||||
});
|
});
|
||||||
await this.db.init();
|
await this.db.init();
|
||||||
this.germanBusinesses = new GermanBusinessData(this);
|
this.jsonLDataProcessor = new JsonlDataProcessor(
|
||||||
await this.germanBusinesses.start();
|
this.config.germanBusinessDataDir,
|
||||||
|
async (entryArg) => {
|
||||||
|
const businessRecord = new this.CBusinessRecord();
|
||||||
|
businessRecord.id = await this.CBusinessRecord.getNewId();
|
||||||
|
businessRecord.data.name = entryArg.name;
|
||||||
|
businessRecord.data.germanParsedRegistration = {
|
||||||
|
court: entryArg.all_attributes.registered_office,
|
||||||
|
number: entryArg.all_attributes._registerNummer,
|
||||||
|
type: entryArg.all_attributes._registerArt as 'HRA' | 'HRB',
|
||||||
|
};
|
||||||
|
await businessRecord.save();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.handelsregister = new HandelsRegister(this, this.config.downloadDir);
|
||||||
|
await this.handelsregister.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async buildInitialDb() {
|
||||||
|
await this.jsonLDataProcessor.processDataFromUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async slowValidateDb() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateSearchByName() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchDbByBusinessNameAndPostalCode(businessNameArg: string, postalCodeArg: string) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
await this.db.close();
|
||||||
|
await this.handelsregister.stop();
|
||||||
}
|
}
|
||||||
public async stop() {}
|
|
||||||
}
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './classes.main.opendata.js';
|
export * from './classes.main.opendata.js';
|
||||||
|
export * from './stocks/index.js';
|
||||||
|
|||||||
@@ -4,8 +4,3 @@ export const packageDir = plugins.path.join(
|
|||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
'../'
|
'../'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const nogitDir = plugins.path.join(packageDir, './.nogit/');
|
|
||||||
plugins.smartfile.fs.ensureDirSync(nogitDir);
|
|
||||||
|
|
||||||
export const germanBusinessDataDir = plugins.path.join(nogitDir, 'germanbusinessdata');
|
|
||||||
@@ -6,24 +6,43 @@ export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
|
import * as lik from '@push.rocks/lik';
|
||||||
import * as qenv from '@push.rocks/qenv';
|
import * as qenv from '@push.rocks/qenv';
|
||||||
import * as smartarchive from '@push.rocks/smartarchive';
|
import * as smartarchive from '@push.rocks/smartarchive';
|
||||||
|
import * as smartarray from '@push.rocks/smartarray';
|
||||||
|
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartstream from '@push.rocks/smartstream';
|
import * as smartstream from '@push.rocks/smartstream';
|
||||||
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
import * as smartxml from '@push.rocks/smartxml';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
lik,
|
||||||
qenv,
|
qenv,
|
||||||
smartarchive,
|
smartarchive,
|
||||||
|
smartarray,
|
||||||
|
smartbrowser,
|
||||||
smartdata,
|
smartdata,
|
||||||
smartdelay,
|
smartdelay,
|
||||||
smartfile,
|
smartfile,
|
||||||
|
smartlog,
|
||||||
smartpath,
|
smartpath,
|
||||||
smartpromise,
|
smartpromise,
|
||||||
smartrequest,
|
smartrequest,
|
||||||
smartstream,
|
smartstream,
|
||||||
|
smartunique,
|
||||||
|
smartxml,
|
||||||
|
}
|
||||||
|
|
||||||
|
// @tsclass scope
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tsclass,
|
||||||
}
|
}
|
||||||
404
ts/stocks/classes.fundamentalsservice.ts
Normal file
404
ts/stocks/classes.fundamentalsservice.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type {
|
||||||
|
IFundamentalsProvider,
|
||||||
|
IFundamentalsProviderConfig,
|
||||||
|
IFundamentalsProviderRegistry,
|
||||||
|
IStockFundamentals,
|
||||||
|
IFundamentalsRequest
|
||||||
|
} from './interfaces/fundamentals.js';
|
||||||
|
|
||||||
|
interface IProviderEntry {
|
||||||
|
provider: IFundamentalsProvider;
|
||||||
|
config: IFundamentalsProviderConfig;
|
||||||
|
lastError?: Error;
|
||||||
|
lastErrorTime?: Date;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICacheEntry {
|
||||||
|
fundamentals: IStockFundamentals | IStockFundamentals[];
|
||||||
|
timestamp: Date;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing fundamental data providers and caching
|
||||||
|
* Parallel to StockPriceService but for fundamental data instead of prices
|
||||||
|
*/
|
||||||
|
export class FundamentalsService implements IFundamentalsProviderRegistry {
|
||||||
|
private providers = new Map<string, IProviderEntry>();
|
||||||
|
private cache = new Map<string, ICacheEntry>();
|
||||||
|
private logger = console;
|
||||||
|
|
||||||
|
private cacheConfig = {
|
||||||
|
ttl: 90 * 24 * 60 * 60 * 1000, // 90 days default (fundamentals change quarterly)
|
||||||
|
maxEntries: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) {
|
||||||
|
if (cacheConfig) {
|
||||||
|
this.cacheConfig = { ...this.cacheConfig, ...cacheConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a fundamentals provider
|
||||||
|
*/
|
||||||
|
public register(provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig): void {
|
||||||
|
const defaultConfig: IFundamentalsProviderConfig = {
|
||||||
|
enabled: true,
|
||||||
|
priority: provider.priority,
|
||||||
|
timeout: 30000, // Longer timeout for fundamental data
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 1000,
|
||||||
|
cacheTTL: this.cacheConfig.ttl
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedConfig = { ...defaultConfig, ...config };
|
||||||
|
|
||||||
|
this.providers.set(provider.name, {
|
||||||
|
provider,
|
||||||
|
config: mergedConfig,
|
||||||
|
successCount: 0,
|
||||||
|
errorCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Registered fundamentals provider: ${provider.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a provider
|
||||||
|
*/
|
||||||
|
public unregister(providerName: string): void {
|
||||||
|
this.providers.delete(providerName);
|
||||||
|
console.log(`Unregistered fundamentals provider: ${providerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific provider by name
|
||||||
|
*/
|
||||||
|
public getProvider(name: string): IFundamentalsProvider | undefined {
|
||||||
|
return this.providers.get(name)?.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered providers
|
||||||
|
*/
|
||||||
|
public getAllProviders(): IFundamentalsProvider[] {
|
||||||
|
return Array.from(this.providers.values()).map(entry => entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled providers sorted by priority
|
||||||
|
*/
|
||||||
|
public getEnabledProviders(): IFundamentalsProvider[] {
|
||||||
|
return Array.from(this.providers.values())
|
||||||
|
.filter(entry => entry.config.enabled)
|
||||||
|
.sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0))
|
||||||
|
.map(entry => entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fundamental data for a single ticker
|
||||||
|
*/
|
||||||
|
public async getFundamentals(ticker: string): Promise<IStockFundamentals> {
|
||||||
|
const result = await this.getData({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker
|
||||||
|
});
|
||||||
|
return result as IStockFundamentals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fundamental data for multiple tickers
|
||||||
|
*/
|
||||||
|
public async getBatchFundamentals(tickers: string[]): Promise<IStockFundamentals[]> {
|
||||||
|
const result = await this.getData({
|
||||||
|
type: 'fundamentals-batch',
|
||||||
|
tickers
|
||||||
|
});
|
||||||
|
return result as IStockFundamentals[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified data fetching method
|
||||||
|
*/
|
||||||
|
public async getData(
|
||||||
|
request: IFundamentalsRequest
|
||||||
|
): Promise<IStockFundamentals | IStockFundamentals[]> {
|
||||||
|
const cacheKey = this.getCacheKey(request);
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
console.log(`Cache hit for ${this.getRequestDescription(request)}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = this.getEnabledProviders();
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new Error('No fundamentals providers available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const entry = this.providers.get(provider.name)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.fetchWithRetry(
|
||||||
|
() => provider.fetchData(request),
|
||||||
|
entry.config
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.successCount++;
|
||||||
|
|
||||||
|
// Use provider-specific cache TTL or default
|
||||||
|
const ttl = entry.config.cacheTTL || this.cacheConfig.ttl;
|
||||||
|
this.addToCache(cacheKey, result, ttl);
|
||||||
|
|
||||||
|
console.log(`Successfully fetched ${this.getRequestDescription(request)} from ${provider.name}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
entry.errorCount++;
|
||||||
|
entry.lastError = error as Error;
|
||||||
|
entry.lastErrorTime = new Date();
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Provider ${provider.name} failed for ${this.getRequestDescription(request)}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch ${this.getRequestDescription(request)} from all providers. Last error: ${lastError?.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich fundamentals with calculated metrics using current price
|
||||||
|
*/
|
||||||
|
public async enrichWithPrice(
|
||||||
|
fundamentals: IStockFundamentals,
|
||||||
|
price: number
|
||||||
|
): Promise<IStockFundamentals> {
|
||||||
|
const enriched = { ...fundamentals };
|
||||||
|
|
||||||
|
// Calculate market cap: price × shares outstanding
|
||||||
|
if (fundamentals.sharesOutstanding) {
|
||||||
|
enriched.marketCap = price * fundamentals.sharesOutstanding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate P/E ratio: price / EPS
|
||||||
|
if (fundamentals.earningsPerShareDiluted && fundamentals.earningsPerShareDiluted > 0) {
|
||||||
|
enriched.priceToEarnings = price / fundamentals.earningsPerShareDiluted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price-to-book: market cap / stockholders equity
|
||||||
|
if (enriched.marketCap && fundamentals.stockholdersEquity && fundamentals.stockholdersEquity > 0) {
|
||||||
|
enriched.priceToBook = enriched.marketCap / fundamentals.stockholdersEquity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich batch fundamentals with prices
|
||||||
|
*/
|
||||||
|
public async enrichBatchWithPrices(
|
||||||
|
fundamentalsList: IStockFundamentals[],
|
||||||
|
priceMap: Map<string, number>
|
||||||
|
): Promise<IStockFundamentals[]> {
|
||||||
|
return Promise.all(
|
||||||
|
fundamentalsList.map(fundamentals => {
|
||||||
|
const price = priceMap.get(fundamentals.ticker);
|
||||||
|
if (price) {
|
||||||
|
return this.enrichWithPrice(fundamentals, price);
|
||||||
|
}
|
||||||
|
return Promise.resolve(fundamentals);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check health of all providers
|
||||||
|
*/
|
||||||
|
public async checkProvidersHealth(): Promise<Map<string, boolean>> {
|
||||||
|
const health = new Map<string, boolean>();
|
||||||
|
|
||||||
|
for (const [name, entry] of this.providers) {
|
||||||
|
if (!entry.config.enabled) {
|
||||||
|
health.set(name, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAvailable = await entry.provider.isAvailable();
|
||||||
|
health.set(name, isAvailable);
|
||||||
|
} catch (error) {
|
||||||
|
health.set(name, false);
|
||||||
|
console.error(`Health check failed for ${name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider statistics
|
||||||
|
*/
|
||||||
|
public getProviderStats(): Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
lastErrorTime?: Date;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const stats = new Map();
|
||||||
|
|
||||||
|
for (const [name, entry] of this.providers) {
|
||||||
|
stats.set(name, {
|
||||||
|
successCount: entry.successCount,
|
||||||
|
errorCount: entry.errorCount,
|
||||||
|
lastError: entry.lastError?.message,
|
||||||
|
lastErrorTime: entry.lastErrorTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached data
|
||||||
|
*/
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
console.log('Fundamentals cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cache TTL
|
||||||
|
*/
|
||||||
|
public setCacheTTL(ttl: number): void {
|
||||||
|
this.cacheConfig.ttl = ttl;
|
||||||
|
console.log(`Fundamentals cache TTL set to ${ttl}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
public getCacheStats(): {
|
||||||
|
size: number;
|
||||||
|
maxEntries: number;
|
||||||
|
ttl: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
maxEntries: this.cacheConfig.maxEntries,
|
||||||
|
ttl: this.cacheConfig.ttl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with retry logic
|
||||||
|
*/
|
||||||
|
private async fetchWithRetry<T>(
|
||||||
|
fetchFn: () => Promise<T>,
|
||||||
|
config: IFundamentalsProviderConfig
|
||||||
|
): Promise<T> {
|
||||||
|
const maxAttempts = config.retryAttempts || 1;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchFn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
const delay = (config.retryDelay || 1000) * attempt;
|
||||||
|
console.log(`Retry attempt ${attempt} after ${delay}ms`);
|
||||||
|
await plugins.smartdelay.delayFor(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Unknown error during fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for request
|
||||||
|
*/
|
||||||
|
private getCacheKey(request: IFundamentalsRequest): string {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'fundamentals-current':
|
||||||
|
return `fundamentals:${request.ticker}`;
|
||||||
|
case 'fundamentals-batch':
|
||||||
|
const tickers = request.tickers.sort().join(',');
|
||||||
|
return `fundamentals-batch:${tickers}`;
|
||||||
|
default:
|
||||||
|
return `unknown:${JSON.stringify(request)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get from cache if not expired
|
||||||
|
*/
|
||||||
|
private getFromCache(key: string): IStockFundamentals | IStockFundamentals[] | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache entry has expired
|
||||||
|
const age = Date.now() - entry.timestamp.getTime();
|
||||||
|
if (entry.ttl !== Infinity && age > entry.ttl) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.fundamentals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add to cache with TTL
|
||||||
|
*/
|
||||||
|
private addToCache(
|
||||||
|
key: string,
|
||||||
|
fundamentals: IStockFundamentals | IStockFundamentals[],
|
||||||
|
ttl?: number
|
||||||
|
): void {
|
||||||
|
// Enforce max entries limit
|
||||||
|
if (this.cache.size >= this.cacheConfig.maxEntries) {
|
||||||
|
// Remove oldest entry
|
||||||
|
const oldestKey = this.cache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
fundamentals,
|
||||||
|
timestamp: new Date(),
|
||||||
|
ttl: ttl || this.cacheConfig.ttl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable request description
|
||||||
|
*/
|
||||||
|
private getRequestDescription(request: IFundamentalsRequest): string {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'fundamentals-current':
|
||||||
|
return `fundamentals for ${request.ticker}`;
|
||||||
|
case 'fundamentals-batch':
|
||||||
|
return `fundamentals for ${request.tickers.length} tickers`;
|
||||||
|
default:
|
||||||
|
return 'fundamentals data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
373
ts/stocks/classes.stockservice.ts
Normal file
373
ts/stocks/classes.stockservice.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig, IProviderRegistry } from './interfaces/provider.js';
|
||||||
|
import type {
|
||||||
|
IStockPrice,
|
||||||
|
IStockPriceError,
|
||||||
|
IStockDataRequest,
|
||||||
|
IStockCurrentRequest,
|
||||||
|
IStockHistoricalRequest,
|
||||||
|
IStockIntradayRequest,
|
||||||
|
IStockBatchCurrentRequest,
|
||||||
|
TIntervalType
|
||||||
|
} from './interfaces/stockprice.js';
|
||||||
|
|
||||||
|
// Simple request interfaces for convenience methods
|
||||||
|
interface ISimpleQuoteRequest {
|
||||||
|
ticker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISimpleBatchRequest {
|
||||||
|
tickers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProviderEntry {
|
||||||
|
provider: IStockProvider;
|
||||||
|
config: IProviderConfig;
|
||||||
|
lastError?: Error;
|
||||||
|
lastErrorTime?: Date;
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICacheEntry {
|
||||||
|
price: IStockPrice | IStockPrice[];
|
||||||
|
timestamp: Date;
|
||||||
|
ttl: number; // Specific TTL for this entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockPriceService implements IProviderRegistry {
|
||||||
|
private providers = new Map<string, IProviderEntry>();
|
||||||
|
private cache = new Map<string, ICacheEntry>();
|
||||||
|
private logger = console;
|
||||||
|
|
||||||
|
private cacheConfig = {
|
||||||
|
ttl: 60000, // 60 seconds default (for backward compatibility)
|
||||||
|
maxEntries: 10000 // Increased for historical data
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(cacheConfig?: { ttl?: number; maxEntries?: number }) {
|
||||||
|
if (cacheConfig) {
|
||||||
|
this.cacheConfig = { ...this.cacheConfig, ...cacheConfig };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data-type aware TTL for smart caching
|
||||||
|
*/
|
||||||
|
private getCacheTTL(dataType: 'eod' | 'historical' | 'intraday' | 'live', interval?: TIntervalType): number {
|
||||||
|
switch (dataType) {
|
||||||
|
case 'historical':
|
||||||
|
return Infinity; // Historical data never changes
|
||||||
|
case 'eod':
|
||||||
|
return 24 * 60 * 60 * 1000; // 24 hours (EOD is static after market close)
|
||||||
|
case 'intraday':
|
||||||
|
// Match cache TTL to interval
|
||||||
|
return this.getIntervalMs(interval);
|
||||||
|
case 'live':
|
||||||
|
return 30 * 1000; // 30 seconds for live data
|
||||||
|
default:
|
||||||
|
return this.cacheConfig.ttl; // Fallback to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert interval to milliseconds
|
||||||
|
*/
|
||||||
|
private getIntervalMs(interval?: TIntervalType): number {
|
||||||
|
if (!interval) return 60 * 1000; // Default 1 minute
|
||||||
|
|
||||||
|
const intervalMap: Record<TIntervalType, number> = {
|
||||||
|
'1min': 60 * 1000,
|
||||||
|
'5min': 5 * 60 * 1000,
|
||||||
|
'10min': 10 * 60 * 1000,
|
||||||
|
'15min': 15 * 60 * 1000,
|
||||||
|
'30min': 30 * 60 * 1000,
|
||||||
|
'1hour': 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
return intervalMap[interval] || 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public register(provider: IStockProvider, config?: IProviderConfig): void {
|
||||||
|
const defaultConfig: IProviderConfig = {
|
||||||
|
enabled: true,
|
||||||
|
priority: provider.priority,
|
||||||
|
timeout: 10000,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelay: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedConfig = { ...defaultConfig, ...config };
|
||||||
|
|
||||||
|
this.providers.set(provider.name, {
|
||||||
|
provider,
|
||||||
|
config: mergedConfig,
|
||||||
|
successCount: 0,
|
||||||
|
errorCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Registered provider: ${provider.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unregister(providerName: string): void {
|
||||||
|
this.providers.delete(providerName);
|
||||||
|
console.log(`Unregistered provider: ${providerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProvider(name: string): IStockProvider | undefined {
|
||||||
|
return this.providers.get(name)?.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllProviders(): IStockProvider[] {
|
||||||
|
return Array.from(this.providers.values()).map(entry => entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEnabledProviders(): IStockProvider[] {
|
||||||
|
return Array.from(this.providers.values())
|
||||||
|
.filter(entry => entry.config.enabled)
|
||||||
|
.sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0))
|
||||||
|
.map(entry => entry.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method: Get current price for a single ticker
|
||||||
|
*/
|
||||||
|
public async getPrice(request: ISimpleQuoteRequest): Promise<IStockPrice> {
|
||||||
|
const result = await this.getData({
|
||||||
|
type: 'current',
|
||||||
|
ticker: request.ticker
|
||||||
|
});
|
||||||
|
return result as IStockPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method: Get current prices for multiple tickers
|
||||||
|
*/
|
||||||
|
public async getPrices(request: ISimpleBatchRequest): Promise<IStockPrice[]> {
|
||||||
|
const result = await this.getData({
|
||||||
|
type: 'batch',
|
||||||
|
tickers: request.tickers
|
||||||
|
});
|
||||||
|
return result as IStockPrice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New unified data fetching method supporting all request types
|
||||||
|
*/
|
||||||
|
public async getData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
|
||||||
|
const cacheKey = this.getDataCacheKey(request);
|
||||||
|
const cached = this.getFromCache(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
console.log(`Cache hit for ${this.getRequestDescription(request)}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = this.getEnabledProviders();
|
||||||
|
if (providers.length === 0) {
|
||||||
|
throw new Error('No stock price providers available');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const entry = this.providers.get(provider.name)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.fetchWithRetry(
|
||||||
|
() => provider.fetchData(request),
|
||||||
|
entry.config
|
||||||
|
) as IStockPrice | IStockPrice[];
|
||||||
|
|
||||||
|
entry.successCount++;
|
||||||
|
|
||||||
|
// Determine TTL based on request type
|
||||||
|
const ttl = this.getRequestTTL(request, result);
|
||||||
|
this.addToCache(cacheKey, result, ttl);
|
||||||
|
|
||||||
|
console.log(`Successfully fetched ${this.getRequestDescription(request)} from ${provider.name}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
entry.errorCount++;
|
||||||
|
entry.lastError = error as Error;
|
||||||
|
entry.lastErrorTime = new Date();
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Provider ${provider.name} failed for ${this.getRequestDescription(request)}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch ${this.getRequestDescription(request)} from all providers. Last error: ${lastError?.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TTL based on request type and result
|
||||||
|
*/
|
||||||
|
private getRequestTTL(request: IStockDataRequest, result: IStockPrice | IStockPrice[]): number {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'historical':
|
||||||
|
return Infinity; // Historical data never changes
|
||||||
|
case 'current':
|
||||||
|
return this.getCacheTTL('eod');
|
||||||
|
case 'batch':
|
||||||
|
return this.getCacheTTL('eod');
|
||||||
|
case 'intraday':
|
||||||
|
return this.getCacheTTL('intraday', request.interval);
|
||||||
|
default:
|
||||||
|
return this.cacheConfig.ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable description of request
|
||||||
|
*/
|
||||||
|
private getRequestDescription(request: IStockDataRequest): string {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'current':
|
||||||
|
return `current price for ${request.ticker}${request.exchange ? ` on ${request.exchange}` : ''}`;
|
||||||
|
case 'historical':
|
||||||
|
return `historical prices for ${request.ticker} from ${request.from.toISOString().split('T')[0]} to ${request.to.toISOString().split('T')[0]}`;
|
||||||
|
case 'intraday':
|
||||||
|
return `intraday ${request.interval} prices for ${request.ticker}`;
|
||||||
|
case 'batch':
|
||||||
|
return `batch prices for ${request.tickers.length} tickers`;
|
||||||
|
default:
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkProvidersHealth(): Promise<Map<string, boolean>> {
|
||||||
|
const health = new Map<string, boolean>();
|
||||||
|
|
||||||
|
for (const [name, entry] of this.providers) {
|
||||||
|
if (!entry.config.enabled) {
|
||||||
|
health.set(name, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAvailable = await entry.provider.isAvailable();
|
||||||
|
health.set(name, isAvailable);
|
||||||
|
} catch (error) {
|
||||||
|
health.set(name, false);
|
||||||
|
console.error(`Health check failed for ${name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProviderStats(): Map<string, {
|
||||||
|
successCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
lastErrorTime?: Date;
|
||||||
|
}> {
|
||||||
|
const stats = new Map();
|
||||||
|
|
||||||
|
for (const [name, entry] of this.providers) {
|
||||||
|
stats.set(name, {
|
||||||
|
successCount: entry.successCount,
|
||||||
|
errorCount: entry.errorCount,
|
||||||
|
lastError: entry.lastError?.message,
|
||||||
|
lastErrorTime: entry.lastErrorTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
console.log('Cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCacheTTL(ttl: number): void {
|
||||||
|
this.cacheConfig.ttl = ttl;
|
||||||
|
console.log(`Cache TTL set to ${ttl}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithRetry<T>(
|
||||||
|
fetchFn: () => Promise<T>,
|
||||||
|
config: IProviderConfig
|
||||||
|
): Promise<T> {
|
||||||
|
const maxAttempts = config.retryAttempts || 1;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchFn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
const delay = (config.retryDelay || 1000) * attempt;
|
||||||
|
console.log(`Retry attempt ${attempt} after ${delay}ms`);
|
||||||
|
await plugins.smartdelay.delayFor(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Unknown error during fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New cache key generation for discriminated union requests
|
||||||
|
*/
|
||||||
|
private getDataCacheKey(request: IStockDataRequest): string {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'current':
|
||||||
|
return `current:${request.ticker}${request.exchange ? `:${request.exchange}` : ''}`;
|
||||||
|
case 'historical':
|
||||||
|
const fromStr = request.from.toISOString().split('T')[0];
|
||||||
|
const toStr = request.to.toISOString().split('T')[0];
|
||||||
|
return `historical:${request.ticker}:${fromStr}:${toStr}${request.exchange ? `:${request.exchange}` : ''}`;
|
||||||
|
case 'intraday':
|
||||||
|
const dateStr = request.date ? request.date.toISOString().split('T')[0] : 'latest';
|
||||||
|
return `intraday:${request.ticker}:${request.interval}:${dateStr}${request.exchange ? `:${request.exchange}` : ''}`;
|
||||||
|
case 'batch':
|
||||||
|
const tickers = request.tickers.sort().join(',');
|
||||||
|
return `batch:${tickers}${request.exchange ? `:${request.exchange}` : ''}`;
|
||||||
|
default:
|
||||||
|
return `unknown:${JSON.stringify(request)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFromCache(key: string): IStockPrice | IStockPrice[] | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache entry has expired
|
||||||
|
const age = Date.now() - entry.timestamp.getTime();
|
||||||
|
if (entry.ttl !== Infinity && age > entry.ttl) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToCache(key: string, price: IStockPrice | IStockPrice[], ttl?: number): void {
|
||||||
|
// Enforce max entries limit
|
||||||
|
if (this.cache.size >= this.cacheConfig.maxEntries) {
|
||||||
|
// Remove oldest entry
|
||||||
|
const oldestKey = this.cache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
price,
|
||||||
|
timestamp: new Date(),
|
||||||
|
ttl: ttl || this.cacheConfig.ttl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ts/stocks/index.ts
Normal file
13
ts/stocks/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Export all interfaces
|
||||||
|
export * from './interfaces/stockprice.js';
|
||||||
|
export * from './interfaces/provider.js';
|
||||||
|
export * from './interfaces/fundamentals.js';
|
||||||
|
|
||||||
|
// Export main services
|
||||||
|
export * from './classes.stockservice.js';
|
||||||
|
export * from './classes.fundamentalsservice.js';
|
||||||
|
|
||||||
|
// Export providers
|
||||||
|
export * from './providers/provider.yahoo.js';
|
||||||
|
export * from './providers/provider.marketstack.js';
|
||||||
|
export * from './providers/provider.secedgar.js';
|
||||||
102
ts/stocks/interfaces/fundamentals.ts
Normal file
102
ts/stocks/interfaces/fundamentals.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Interfaces for stock fundamental data (financials from SEC filings)
|
||||||
|
* Separate from stock price data (OHLCV) to maintain clean architecture
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Request types for fundamental data
|
||||||
|
export interface IFundamentalsCurrentRequest {
|
||||||
|
type: 'fundamentals-current';
|
||||||
|
ticker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFundamentalsBatchRequest {
|
||||||
|
type: 'fundamentals-batch';
|
||||||
|
tickers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IFundamentalsRequest =
|
||||||
|
| IFundamentalsCurrentRequest
|
||||||
|
| IFundamentalsBatchRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock fundamental data from SEC filings (10-K, 10-Q)
|
||||||
|
* Contains financial metrics like EPS, Revenue, Assets, etc.
|
||||||
|
*/
|
||||||
|
export interface IStockFundamentals {
|
||||||
|
ticker: string;
|
||||||
|
cik: string;
|
||||||
|
companyName: string;
|
||||||
|
provider: string;
|
||||||
|
timestamp: Date;
|
||||||
|
fetchedAt: Date;
|
||||||
|
|
||||||
|
// Per-share metrics
|
||||||
|
earningsPerShareBasic?: number;
|
||||||
|
earningsPerShareDiluted?: number;
|
||||||
|
sharesOutstanding?: number;
|
||||||
|
weightedAverageSharesOutstanding?: number;
|
||||||
|
|
||||||
|
// Income statement (annual USD)
|
||||||
|
revenue?: number;
|
||||||
|
netIncome?: number;
|
||||||
|
operatingIncome?: number;
|
||||||
|
grossProfit?: number;
|
||||||
|
costOfRevenue?: number;
|
||||||
|
|
||||||
|
// Balance sheet (annual USD)
|
||||||
|
assets?: number;
|
||||||
|
liabilities?: number;
|
||||||
|
stockholdersEquity?: number;
|
||||||
|
cash?: number;
|
||||||
|
propertyPlantEquipment?: number;
|
||||||
|
|
||||||
|
// Calculated metrics (requires current price)
|
||||||
|
marketCap?: number; // price × sharesOutstanding
|
||||||
|
priceToEarnings?: number; // price / EPS
|
||||||
|
priceToBook?: number; // marketCap / stockholdersEquity
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
fiscalYear?: string;
|
||||||
|
fiscalQuarter?: string;
|
||||||
|
filingDate?: Date;
|
||||||
|
form?: '10-K' | '10-Q' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider interface for fetching fundamental data
|
||||||
|
* Parallel to IStockProvider but for fundamentals instead of prices
|
||||||
|
*/
|
||||||
|
export interface IFundamentalsProvider {
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
fetchData(request: IFundamentalsRequest): Promise<IStockFundamentals | IStockFundamentals[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
readonly requiresAuth: boolean;
|
||||||
|
readonly rateLimit?: {
|
||||||
|
requestsPerMinute: number;
|
||||||
|
requestsPerDay?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for fundamentals providers
|
||||||
|
*/
|
||||||
|
export interface IFundamentalsProviderConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
priority?: number;
|
||||||
|
timeout?: number;
|
||||||
|
retryAttempts?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
cacheTTL?: number; // Custom cache TTL for this provider
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for managing fundamental data providers
|
||||||
|
*/
|
||||||
|
export interface IFundamentalsProviderRegistry {
|
||||||
|
register(provider: IFundamentalsProvider, config?: IFundamentalsProviderConfig): void;
|
||||||
|
unregister(providerName: string): void;
|
||||||
|
getProvider(name: string): IFundamentalsProvider | undefined;
|
||||||
|
getAllProviders(): IFundamentalsProvider[];
|
||||||
|
getEnabledProviders(): IFundamentalsProvider[];
|
||||||
|
}
|
||||||
35
ts/stocks/interfaces/provider.ts
Normal file
35
ts/stocks/interfaces/provider.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { IStockPrice, IStockDataRequest } from './stockprice.js';
|
||||||
|
|
||||||
|
export interface IStockProvider {
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
|
||||||
|
supportsMarket?(market: string): boolean;
|
||||||
|
supportsTicker?(ticker: string): boolean;
|
||||||
|
|
||||||
|
readonly requiresAuth: boolean;
|
||||||
|
readonly rateLimit?: {
|
||||||
|
requestsPerMinute: number;
|
||||||
|
requestsPerDay?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProviderConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
priority?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
timeout?: number;
|
||||||
|
retryAttempts?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProviderRegistry {
|
||||||
|
register(provider: IStockProvider, config?: IProviderConfig): void;
|
||||||
|
unregister(providerName: string): void;
|
||||||
|
getProvider(name: string): IStockProvider | undefined;
|
||||||
|
getAllProviders(): IStockProvider[];
|
||||||
|
getEnabledProviders(): IStockProvider[];
|
||||||
|
}
|
||||||
94
ts/stocks/interfaces/stockprice.ts
Normal file
94
ts/stocks/interfaces/stockprice.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Enhanced stock price interface with additional OHLCV data
|
||||||
|
export interface IStockPrice {
|
||||||
|
ticker: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
previousClose: number;
|
||||||
|
timestamp: Date;
|
||||||
|
provider: string;
|
||||||
|
marketState: 'PRE' | 'REGULAR' | 'POST' | 'CLOSED';
|
||||||
|
exchange?: string;
|
||||||
|
exchangeName?: string;
|
||||||
|
|
||||||
|
// Phase 1 enhancements
|
||||||
|
volume?: number; // Trading volume
|
||||||
|
open?: number; // Opening price
|
||||||
|
high?: number; // Day high
|
||||||
|
low?: number; // Day low
|
||||||
|
adjusted?: boolean; // If price is split/dividend adjusted
|
||||||
|
dataType: 'eod' | 'intraday' | 'live'; // What kind of data this is
|
||||||
|
fetchedAt: Date; // When we fetched (vs data timestamp)
|
||||||
|
|
||||||
|
// Company identification
|
||||||
|
companyName?: string; // Company name (e.g., "Apple Inc.")
|
||||||
|
companyFullName?: string; // Full company name with exchange (e.g., "Apple Inc. (NASDAQ:AAPL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStockPriceError {
|
||||||
|
ticker: string;
|
||||||
|
error: string;
|
||||||
|
provider: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination support for large datasets
|
||||||
|
export interface IPaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalRecords: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Discriminated union types for different request types
|
||||||
|
export type TIntervalType = '1min' | '5min' | '10min' | '15min' | '30min' | '1hour';
|
||||||
|
export type TSortOrder = 'ASC' | 'DESC';
|
||||||
|
|
||||||
|
// Current price request (latest EOD or live)
|
||||||
|
export interface IStockCurrentRequest {
|
||||||
|
type: 'current';
|
||||||
|
ticker: string;
|
||||||
|
exchange?: string; // MIC code like 'XNAS', 'XNYS', 'XLON'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historical price request (date range)
|
||||||
|
export interface IStockHistoricalRequest {
|
||||||
|
type: 'historical';
|
||||||
|
ticker: string;
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
exchange?: string;
|
||||||
|
sort?: TSortOrder;
|
||||||
|
limit?: number; // Max results per page (default 1000)
|
||||||
|
offset?: number; // For pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intraday price request (real-time intervals)
|
||||||
|
export interface IStockIntradayRequest {
|
||||||
|
type: 'intraday';
|
||||||
|
ticker: string;
|
||||||
|
interval: TIntervalType;
|
||||||
|
exchange?: string;
|
||||||
|
limit?: number; // Number of bars to return
|
||||||
|
date?: Date; // Specific date for historical intraday
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch current prices request
|
||||||
|
export interface IStockBatchCurrentRequest {
|
||||||
|
type: 'batch';
|
||||||
|
tickers: string[];
|
||||||
|
exchange?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for all stock data requests
|
||||||
|
export type IStockDataRequest =
|
||||||
|
| IStockCurrentRequest
|
||||||
|
| IStockHistoricalRequest
|
||||||
|
| IStockIntradayRequest
|
||||||
|
| IStockBatchCurrentRequest;
|
||||||
376
ts/stocks/providers/provider.marketstack.ts
Normal file
376
ts/stocks/providers/provider.marketstack.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
||||||
|
import type {
|
||||||
|
IStockPrice,
|
||||||
|
IStockDataRequest,
|
||||||
|
IStockCurrentRequest,
|
||||||
|
IStockHistoricalRequest,
|
||||||
|
IStockIntradayRequest,
|
||||||
|
IStockBatchCurrentRequest
|
||||||
|
} from '../interfaces/stockprice.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketstack API v2 Provider - Enhanced
|
||||||
|
* Documentation: https://docs.apilayer.com/marketstack/docs/marketstack-api-v2-v-2-0-0
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - End-of-Day (EOD) stock prices with historical data
|
||||||
|
* - Intraday pricing with multiple intervals (1min, 5min, 15min, 30min, 1hour)
|
||||||
|
* - Exchange filtering via MIC codes (XNAS, XNYS, XLON, etc.)
|
||||||
|
* - Supports 500,000+ tickers across 72+ exchanges worldwide
|
||||||
|
* - OHLCV data (Open, High, Low, Close, Volume)
|
||||||
|
* - Pagination for large datasets
|
||||||
|
* - Requires API key authentication
|
||||||
|
*
|
||||||
|
* Rate Limits:
|
||||||
|
* - Free Plan: 100 requests/month (EOD only)
|
||||||
|
* - Basic Plan: 10,000 requests/month
|
||||||
|
* - Professional Plan: 100,000 requests/month (intraday access)
|
||||||
|
*
|
||||||
|
* Phase 1 Enhancements:
|
||||||
|
* - Historical data retrieval with date ranges
|
||||||
|
* - Exchange filtering
|
||||||
|
* - OHLCV data support
|
||||||
|
* - Pagination handling
|
||||||
|
*/
|
||||||
|
export class MarketstackProvider implements IStockProvider {
|
||||||
|
public name = 'Marketstack';
|
||||||
|
public priority = 80; // Lower than Yahoo (100) due to rate limits and EOD-only data
|
||||||
|
public readonly requiresAuth = true;
|
||||||
|
public readonly rateLimit = {
|
||||||
|
requestsPerMinute: undefined, // No per-minute limit specified
|
||||||
|
requestsPerDay: undefined // Varies by plan
|
||||||
|
};
|
||||||
|
|
||||||
|
private logger = console;
|
||||||
|
private baseUrl = 'https://api.marketstack.com/v2';
|
||||||
|
private apiKey: string;
|
||||||
|
|
||||||
|
constructor(apiKey: string, private config?: IProviderConfig) {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('API key is required for Marketstack provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified data fetching method supporting all request types
|
||||||
|
*/
|
||||||
|
public async fetchData(request: IStockDataRequest): Promise<IStockPrice[] | IStockPrice> {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'current':
|
||||||
|
return this.fetchCurrentPrice(request);
|
||||||
|
case 'historical':
|
||||||
|
return this.fetchHistoricalPrices(request);
|
||||||
|
case 'intraday':
|
||||||
|
return this.fetchIntradayPrices(request);
|
||||||
|
case 'batch':
|
||||||
|
return this.fetchBatchCurrentPrices(request);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request type: ${(request as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current/latest EOD price for a single ticker (new API)
|
||||||
|
*/
|
||||||
|
private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
|
||||||
|
try {
|
||||||
|
let url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`;
|
||||||
|
|
||||||
|
// Add exchange filter if specified
|
||||||
|
if (request.exchange) {
|
||||||
|
url += `&exchange=${request.exchange}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.timeout(this.config?.timeout || 10000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
|
// Check for API errors
|
||||||
|
if (responseData.error) {
|
||||||
|
throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single ticker endpoint, response is direct object (not wrapped in data field)
|
||||||
|
if (!responseData || !responseData.close) {
|
||||||
|
throw new Error(`No data found for ticker ${request.ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToStockPrice(responseData, 'eod');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch current price for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`Marketstack: Failed to fetch current price for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch historical EOD prices for a ticker with date range
|
||||||
|
*/
|
||||||
|
private async fetchHistoricalPrices(request: IStockHistoricalRequest): Promise<IStockPrice[]> {
|
||||||
|
try {
|
||||||
|
const allPrices: IStockPrice[] = [];
|
||||||
|
let offset = request.offset || 0;
|
||||||
|
const limit = request.limit || 1000; // Max per page
|
||||||
|
const maxRecords = 10000; // Safety limit
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let url = `${this.baseUrl}/eod?access_key=${this.apiKey}`;
|
||||||
|
url += `&symbols=${request.ticker}`;
|
||||||
|
url += `&date_from=${this.formatDate(request.from)}`;
|
||||||
|
url += `&date_to=${this.formatDate(request.to)}`;
|
||||||
|
url += `&limit=${limit}`;
|
||||||
|
url += `&offset=${offset}`;
|
||||||
|
|
||||||
|
if (request.exchange) {
|
||||||
|
url += `&exchange=${request.exchange}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.sort) {
|
||||||
|
url += `&sort=${request.sort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.timeout(this.config?.timeout || 15000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
|
// Check for API errors
|
||||||
|
if (responseData.error) {
|
||||||
|
throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseData?.data || !Array.isArray(responseData.data)) {
|
||||||
|
throw new Error('Invalid response format from Marketstack API');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map data to stock prices
|
||||||
|
for (const data of responseData.data) {
|
||||||
|
try {
|
||||||
|
allPrices.push(this.mapToStockPrice(data, 'eod'));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to parse historical data for ${data.symbol}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have more pages
|
||||||
|
const pagination = responseData.pagination;
|
||||||
|
const hasMore = pagination && offset + limit < pagination.total;
|
||||||
|
|
||||||
|
// Safety check: don't fetch more than maxRecords
|
||||||
|
if (!hasMore || allPrices.length >= maxRecords) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPrices;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch historical prices for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`Marketstack: Failed to fetch historical prices for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch intraday prices with specified interval (Phase 2 placeholder)
|
||||||
|
*/
|
||||||
|
private async fetchIntradayPrices(request: IStockIntradayRequest): Promise<IStockPrice[]> {
|
||||||
|
throw new Error('Intraday data support coming in Phase 2. For now, use EOD data with type: "current" or "historical"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current prices for multiple tickers (new API)
|
||||||
|
*/
|
||||||
|
private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
|
||||||
|
try {
|
||||||
|
const symbols = request.tickers.join(',');
|
||||||
|
let url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`;
|
||||||
|
|
||||||
|
if (request.exchange) {
|
||||||
|
url += `&exchange=${request.exchange}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.timeout(this.config?.timeout || 15000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
|
// Check for API errors
|
||||||
|
if (responseData.error) {
|
||||||
|
throw new Error(`Marketstack API error: ${responseData.error.message || JSON.stringify(responseData.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseData?.data || !Array.isArray(responseData.data)) {
|
||||||
|
throw new Error('Invalid response format from Marketstack API');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices: IStockPrice[] = [];
|
||||||
|
|
||||||
|
for (const data of responseData.data) {
|
||||||
|
try {
|
||||||
|
prices.push(this.mapToStockPrice(data, 'eod'));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to parse data for ${data.symbol}:`, error);
|
||||||
|
// Continue processing other tickers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
throw new Error('No valid price data received from batch request');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch batch current prices:`, error);
|
||||||
|
throw new Error(`Marketstack: Failed to fetch batch current prices: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Marketstack API is available and accessible
|
||||||
|
*/
|
||||||
|
public async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Test with a well-known ticker
|
||||||
|
const url = `${this.baseUrl}/tickers/AAPL/eod/latest?access_key=${this.apiKey}`;
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.timeout(5000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
|
// Check if we got valid data (not an error)
|
||||||
|
// Single ticker endpoint returns direct object, not wrapped in data field
|
||||||
|
return !responseData.error && responseData.close !== undefined;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Marketstack provider is not available:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a market is supported
|
||||||
|
* Marketstack supports 72+ exchanges worldwide
|
||||||
|
*/
|
||||||
|
public supportsMarket(market: string): boolean {
|
||||||
|
// Marketstack has broad international coverage including:
|
||||||
|
// US, UK, DE, FR, JP, CN, HK, AU, CA, IN, etc.
|
||||||
|
const supportedMarkets = [
|
||||||
|
'US', 'UK', 'GB', 'DE', 'FR', 'JP', 'CN', 'HK', 'AU', 'CA',
|
||||||
|
'IN', 'BR', 'MX', 'IT', 'ES', 'NL', 'SE', 'CH', 'NO', 'DK'
|
||||||
|
];
|
||||||
|
return supportedMarkets.includes(market.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a ticker format is supported
|
||||||
|
*/
|
||||||
|
public supportsTicker(ticker: string): boolean {
|
||||||
|
// Basic validation - Marketstack supports most standard ticker formats
|
||||||
|
return /^[A-Z0-9\.\-]{1,10}$/.test(ticker.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Marketstack API response to IStockPrice interface
|
||||||
|
*/
|
||||||
|
private mapToStockPrice(data: any, dataType: 'eod' | 'intraday' | 'live' = 'eod'): IStockPrice {
|
||||||
|
if (!data.close) {
|
||||||
|
throw new Error('Missing required price data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate change and change percent
|
||||||
|
// EOD data: previous close is typically open price of the same day
|
||||||
|
// For better accuracy, we'd need previous day's close, but that requires another API call
|
||||||
|
const currentPrice = data.close;
|
||||||
|
const previousClose = data.open || currentPrice;
|
||||||
|
const change = currentPrice - previousClose;
|
||||||
|
const changePercent = previousClose !== 0 ? (change / previousClose) * 100 : 0;
|
||||||
|
|
||||||
|
// Parse timestamp
|
||||||
|
const timestamp = data.date ? new Date(data.date) : new Date();
|
||||||
|
const fetchedAt = new Date();
|
||||||
|
|
||||||
|
const stockPrice: IStockPrice = {
|
||||||
|
ticker: data.symbol.toUpperCase(),
|
||||||
|
price: currentPrice,
|
||||||
|
currency: data.price_currency || 'USD',
|
||||||
|
change: change,
|
||||||
|
changePercent: changePercent,
|
||||||
|
previousClose: previousClose,
|
||||||
|
timestamp: timestamp,
|
||||||
|
provider: this.name,
|
||||||
|
marketState: 'CLOSED', // EOD data is always for closed markets
|
||||||
|
exchange: data.exchange,
|
||||||
|
exchangeName: data.exchange_code || data.name,
|
||||||
|
|
||||||
|
// Phase 1 enhancements: OHLCV data
|
||||||
|
volume: data.volume,
|
||||||
|
open: data.open,
|
||||||
|
high: data.high,
|
||||||
|
low: data.low,
|
||||||
|
adjusted: data.adj_close !== undefined, // If adj_close exists, price is adjusted
|
||||||
|
dataType: dataType,
|
||||||
|
fetchedAt: fetchedAt,
|
||||||
|
|
||||||
|
// Company identification
|
||||||
|
companyName: data.company_name || data.name || undefined,
|
||||||
|
companyFullName: this.buildCompanyFullName(data)
|
||||||
|
};
|
||||||
|
|
||||||
|
return stockPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full company name with exchange and ticker information
|
||||||
|
* Example: "Apple Inc (NASDAQ:AAPL)"
|
||||||
|
*/
|
||||||
|
private buildCompanyFullName(data: any): string | undefined {
|
||||||
|
// Check if API already provides full name
|
||||||
|
if (data.full_name || data.long_name) {
|
||||||
|
return data.full_name || data.long_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build from available data
|
||||||
|
const companyName = data.company_name || data.name;
|
||||||
|
const exchangeCode = data.exchange_code; // e.g., "NASDAQ"
|
||||||
|
const symbol = data.symbol; // e.g., "AAPL"
|
||||||
|
|
||||||
|
if (!companyName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have exchange and symbol, build full name: "Apple Inc (NASDAQ:AAPL)"
|
||||||
|
if (exchangeCode && symbol) {
|
||||||
|
return `${companyName} (${exchangeCode}:${symbol})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we only have symbol: "Apple Inc (AAPL)"
|
||||||
|
if (symbol) {
|
||||||
|
return `${companyName} (${symbol})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise just return company name
|
||||||
|
return companyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to YYYY-MM-DD for API requests
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
429
ts/stocks/providers/provider.secedgar.ts
Normal file
429
ts/stocks/providers/provider.secedgar.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type {
|
||||||
|
IFundamentalsProvider,
|
||||||
|
IStockFundamentals,
|
||||||
|
IFundamentalsRequest,
|
||||||
|
IFundamentalsCurrentRequest,
|
||||||
|
IFundamentalsBatchRequest
|
||||||
|
} from '../interfaces/fundamentals.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for SEC EDGAR provider
|
||||||
|
*/
|
||||||
|
export interface ISecEdgarConfig {
|
||||||
|
userAgent: string; // Required: Format "Company Name Email" (e.g., "fin.cx info@fin.cx")
|
||||||
|
cikCacheTTL?: number; // Default: 30 days (CIK codes rarely change)
|
||||||
|
fundamentalsCacheTTL?: number; // Default: 90 days (quarterly filings)
|
||||||
|
timeout?: number; // Request timeout in ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter for SEC EDGAR API
|
||||||
|
* SEC requires: 10 requests per second maximum
|
||||||
|
*/
|
||||||
|
class RateLimiter {
|
||||||
|
private requestTimes: number[] = [];
|
||||||
|
private readonly maxRequestsPerSecond = 10;
|
||||||
|
|
||||||
|
public async waitForSlot(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const oneSecondAgo = now - 1000;
|
||||||
|
|
||||||
|
// Remove requests older than 1 second
|
||||||
|
this.requestTimes = this.requestTimes.filter(time => time > oneSecondAgo);
|
||||||
|
|
||||||
|
// If we've hit the limit, wait
|
||||||
|
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
|
||||||
|
const oldestRequest = this.requestTimes[0];
|
||||||
|
const waitTime = 1000 - (now - oldestRequest) + 10; // +10ms buffer
|
||||||
|
await plugins.smartdelay.delayFor(waitTime);
|
||||||
|
return this.waitForSlot(); // Recursively check again
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this request
|
||||||
|
this.requestTimes.push(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEC EDGAR Fundamental Data Provider
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Free public access (no API key required)
|
||||||
|
* - All US public companies
|
||||||
|
* - Financial data from 10-K/10-Q filings
|
||||||
|
* - US GAAP standardized metrics
|
||||||
|
* - Historical data back to ~2009
|
||||||
|
* - < 1 minute filing delay
|
||||||
|
*
|
||||||
|
* Documentation: https://www.sec.gov/edgar/sec-api-documentation
|
||||||
|
*
|
||||||
|
* Rate Limits:
|
||||||
|
* - 10 requests per second (enforced by SEC)
|
||||||
|
* - Requires User-Agent header in format: "Company Name Email"
|
||||||
|
*
|
||||||
|
* Data Sources:
|
||||||
|
* - Company Facts API: /api/xbrl/companyfacts/CIK##########.json
|
||||||
|
* - Ticker Lookup: /files/company_tickers.json
|
||||||
|
*/
|
||||||
|
export class SecEdgarProvider implements IFundamentalsProvider {
|
||||||
|
public name = 'SEC EDGAR';
|
||||||
|
public priority = 100; // High priority - free, authoritative, comprehensive
|
||||||
|
public readonly requiresAuth = false; // No API key needed!
|
||||||
|
public readonly rateLimit = {
|
||||||
|
requestsPerMinute: 600, // 10 requests/second = 600/minute
|
||||||
|
requestsPerDay: undefined // No daily limit
|
||||||
|
};
|
||||||
|
|
||||||
|
private logger = console;
|
||||||
|
private baseUrl = 'https://data.sec.gov/api/xbrl';
|
||||||
|
private tickersUrl = 'https://www.sec.gov/files/company_tickers.json';
|
||||||
|
private userAgent: string;
|
||||||
|
private config: Required<ISecEdgarConfig>;
|
||||||
|
|
||||||
|
// Caching
|
||||||
|
private cikCache = new Map<string, { cik: string; timestamp: Date }>();
|
||||||
|
private tickerListCache: { data: any; timestamp: Date } | null = null;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
private rateLimiter = new RateLimiter();
|
||||||
|
|
||||||
|
constructor(config: ISecEdgarConfig) {
|
||||||
|
// Validate User-Agent
|
||||||
|
if (!config.userAgent) {
|
||||||
|
throw new Error('User-Agent is required for SEC EDGAR provider (format: "Company Name Email")');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate User-Agent format (must contain space and @ symbol)
|
||||||
|
if (!config.userAgent.includes(' ') || !config.userAgent.includes('@')) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid User-Agent format. Required: "Company Name Email" (e.g., "fin.cx info@fin.cx")'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userAgent = config.userAgent;
|
||||||
|
this.config = {
|
||||||
|
userAgent: config.userAgent,
|
||||||
|
cikCacheTTL: config.cikCacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
fundamentalsCacheTTL: config.fundamentalsCacheTTL || 90 * 24 * 60 * 60 * 1000, // 90 days
|
||||||
|
timeout: config.timeout || 30000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified data fetching method
|
||||||
|
*/
|
||||||
|
public async fetchData(
|
||||||
|
request: IFundamentalsRequest
|
||||||
|
): Promise<IStockFundamentals | IStockFundamentals[]> {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'fundamentals-current':
|
||||||
|
return this.fetchFundamentals(request);
|
||||||
|
case 'fundamentals-batch':
|
||||||
|
return this.fetchBatchFundamentals(request);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request type: ${(request as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch fundamental data for a single ticker
|
||||||
|
*/
|
||||||
|
private async fetchFundamentals(request: IFundamentalsCurrentRequest): Promise<IStockFundamentals> {
|
||||||
|
try {
|
||||||
|
// 1. Get CIK for ticker (with caching)
|
||||||
|
const cik = await this.getCIK(request.ticker);
|
||||||
|
|
||||||
|
// 2. Fetch company facts from SEC (with rate limiting)
|
||||||
|
const companyFacts = await this.fetchCompanyFacts(cik);
|
||||||
|
|
||||||
|
// 3. Parse facts into structured fundamental data
|
||||||
|
return this.parseCompanyFacts(request.ticker, cik, companyFacts);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch fundamentals for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`SEC EDGAR: Failed to fetch fundamentals for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch fundamental data for multiple tickers
|
||||||
|
*/
|
||||||
|
private async fetchBatchFundamentals(
|
||||||
|
request: IFundamentalsBatchRequest
|
||||||
|
): Promise<IStockFundamentals[]> {
|
||||||
|
const results: IStockFundamentals[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const ticker of request.tickers) {
|
||||||
|
try {
|
||||||
|
const fundamentals = await this.fetchFundamentals({
|
||||||
|
type: 'fundamentals-current',
|
||||||
|
ticker
|
||||||
|
});
|
||||||
|
results.push(fundamentals);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to fetch fundamentals for ${ticker}:`, error);
|
||||||
|
errors.push(`${ticker}: ${error.message}`);
|
||||||
|
// Continue with other tickers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
throw new Error(`Failed to fetch fundamentals for all tickers. Errors: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CIK (Central Index Key) for a ticker symbol
|
||||||
|
* Uses SEC's public ticker-to-CIK mapping file
|
||||||
|
*/
|
||||||
|
private async getCIK(ticker: string): Promise<string> {
|
||||||
|
const tickerUpper = ticker.toUpperCase();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.cikCache.get(tickerUpper);
|
||||||
|
if (cached) {
|
||||||
|
const age = Date.now() - cached.timestamp.getTime();
|
||||||
|
if (age < this.config.cikCacheTTL) {
|
||||||
|
return cached.cik;
|
||||||
|
}
|
||||||
|
// Cache expired, remove it
|
||||||
|
this.cikCache.delete(tickerUpper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ticker list (with caching at list level)
|
||||||
|
const tickers = await this.fetchTickerList();
|
||||||
|
|
||||||
|
// Find ticker in list (case-insensitive)
|
||||||
|
const entry = Object.values(tickers).find((t: any) => t.ticker === tickerUpper);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error(`CIK not found for ticker ${ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cik = String((entry as any).cik_str);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.cikCache.set(tickerUpper, {
|
||||||
|
cik,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
return cik;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the SEC ticker-to-CIK mapping list
|
||||||
|
* Cached for 24 hours (list updates daily)
|
||||||
|
*/
|
||||||
|
private async fetchTickerList(): Promise<any> {
|
||||||
|
// Check cache
|
||||||
|
if (this.tickerListCache) {
|
||||||
|
const age = Date.now() - this.tickerListCache.timestamp.getTime();
|
||||||
|
if (age < 24 * 60 * 60 * 1000) { // 24 hours
|
||||||
|
return this.tickerListCache.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for rate limit slot
|
||||||
|
await this.rateLimiter.waitForSlot();
|
||||||
|
|
||||||
|
// Fetch from SEC
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(this.tickersUrl)
|
||||||
|
.headers({
|
||||||
|
'User-Agent': this.userAgent,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
})
|
||||||
|
.timeout(this.config.timeout)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Cache the list
|
||||||
|
this.tickerListCache = {
|
||||||
|
data,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch company facts from SEC EDGAR
|
||||||
|
*/
|
||||||
|
private async fetchCompanyFacts(cik: string): Promise<any> {
|
||||||
|
// Pad CIK to 10 digits
|
||||||
|
const paddedCIK = cik.padStart(10, '0');
|
||||||
|
const url = `${this.baseUrl}/companyfacts/CIK${paddedCIK}.json`;
|
||||||
|
|
||||||
|
// Wait for rate limit slot
|
||||||
|
await this.rateLimiter.waitForSlot();
|
||||||
|
|
||||||
|
// Fetch from SEC
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.headers({
|
||||||
|
'User-Agent': this.userAgent,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Encoding': 'gzip, deflate',
|
||||||
|
'Host': 'data.sec.gov'
|
||||||
|
})
|
||||||
|
.timeout(this.config.timeout)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
if (!data || !data.facts) {
|
||||||
|
throw new Error('Invalid response from SEC EDGAR API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SEC company facts into structured fundamental data
|
||||||
|
*/
|
||||||
|
private parseCompanyFacts(ticker: string, cik: string, data: any): IStockFundamentals {
|
||||||
|
const usGaap = data.facts?.['us-gaap'];
|
||||||
|
|
||||||
|
if (!usGaap) {
|
||||||
|
throw new Error('No US GAAP data available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract latest values for key metrics
|
||||||
|
const fundamentals: IStockFundamentals = {
|
||||||
|
ticker: ticker.toUpperCase(),
|
||||||
|
cik: cik,
|
||||||
|
companyName: data.entityName,
|
||||||
|
provider: this.name,
|
||||||
|
timestamp: new Date(),
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
|
||||||
|
// Per-share metrics
|
||||||
|
earningsPerShareBasic: this.getLatestValue(usGaap, 'EarningsPerShareBasic'),
|
||||||
|
earningsPerShareDiluted: this.getLatestValue(usGaap, 'EarningsPerShareDiluted'),
|
||||||
|
sharesOutstanding: this.getLatestValue(usGaap, 'CommonStockSharesOutstanding'),
|
||||||
|
weightedAverageSharesOutstanding: this.getLatestValue(
|
||||||
|
usGaap,
|
||||||
|
'WeightedAverageNumberOfSharesOutstandingBasic'
|
||||||
|
),
|
||||||
|
|
||||||
|
// Income statement
|
||||||
|
revenue: this.getLatestValue(usGaap, 'Revenues') ||
|
||||||
|
this.getLatestValue(usGaap, 'RevenueFromContractWithCustomerExcludingAssessedTax'),
|
||||||
|
netIncome: this.getLatestValue(usGaap, 'NetIncomeLoss'),
|
||||||
|
operatingIncome: this.getLatestValue(usGaap, 'OperatingIncomeLoss'),
|
||||||
|
grossProfit: this.getLatestValue(usGaap, 'GrossProfit'),
|
||||||
|
costOfRevenue: this.getLatestValue(usGaap, 'CostOfRevenue'),
|
||||||
|
|
||||||
|
// Balance sheet
|
||||||
|
assets: this.getLatestValue(usGaap, 'Assets'),
|
||||||
|
liabilities: this.getLatestValue(usGaap, 'Liabilities'),
|
||||||
|
stockholdersEquity: this.getLatestValue(usGaap, 'StockholdersEquity'),
|
||||||
|
cash: this.getLatestValue(usGaap, 'CashAndCashEquivalentsAtCarryingValue'),
|
||||||
|
propertyPlantEquipment: this.getLatestValue(usGaap, 'PropertyPlantAndEquipmentNet'),
|
||||||
|
|
||||||
|
// Metadata (from latest available data point)
|
||||||
|
fiscalYear: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fy,
|
||||||
|
fiscalQuarter: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.fp,
|
||||||
|
filingDate: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.filed
|
||||||
|
? new Date(this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')!.filed)
|
||||||
|
: undefined,
|
||||||
|
form: this.getLatestMetadata(usGaap, 'EarningsPerShareDiluted')?.form
|
||||||
|
};
|
||||||
|
|
||||||
|
return fundamentals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest value for a US GAAP metric
|
||||||
|
*/
|
||||||
|
private getLatestValue(usGaap: any, metricName: string): number | undefined {
|
||||||
|
const metric = usGaap[metricName];
|
||||||
|
if (!metric?.units) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first unit type (USD, shares, etc.)
|
||||||
|
const unitType = Object.keys(metric.units)[0];
|
||||||
|
const values = metric.units[unitType];
|
||||||
|
|
||||||
|
if (!values || !Array.isArray(values) || values.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest value (last in array)
|
||||||
|
const latest = values[values.length - 1];
|
||||||
|
return latest?.val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata from the latest data point
|
||||||
|
*/
|
||||||
|
private getLatestMetadata(usGaap: any, metricName: string): any | undefined {
|
||||||
|
const metric = usGaap[metricName];
|
||||||
|
if (!metric?.units) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitType = Object.keys(metric.units)[0];
|
||||||
|
const values = metric.units[unitType];
|
||||||
|
|
||||||
|
if (!values || !Array.isArray(values) || values.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values[values.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SEC EDGAR API is available
|
||||||
|
*/
|
||||||
|
public async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Test with Apple's well-known CIK
|
||||||
|
const url = `${this.baseUrl}/companyfacts/CIK0000320193.json`;
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.headers({
|
||||||
|
'User-Agent': this.userAgent,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
})
|
||||||
|
.timeout(5000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data && data.facts !== undefined;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('SEC EDGAR provider is not available:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
public getCacheStats(): {
|
||||||
|
cikCacheSize: number;
|
||||||
|
hasTickerList: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
cikCacheSize: this.cikCache.size,
|
||||||
|
hasTickerList: this.tickerListCache !== null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all caches
|
||||||
|
*/
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cikCache.clear();
|
||||||
|
this.tickerListCache = null;
|
||||||
|
this.logger.log('SEC EDGAR cache cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
194
ts/stocks/providers/provider.yahoo.ts
Normal file
194
ts/stocks/providers/provider.yahoo.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
||||||
|
import type {
|
||||||
|
IStockPrice,
|
||||||
|
IStockDataRequest,
|
||||||
|
IStockCurrentRequest,
|
||||||
|
IStockBatchCurrentRequest
|
||||||
|
} from '../interfaces/stockprice.js';
|
||||||
|
|
||||||
|
export class YahooFinanceProvider implements IStockProvider {
|
||||||
|
public name = 'Yahoo Finance';
|
||||||
|
public priority = 100;
|
||||||
|
public readonly requiresAuth = false;
|
||||||
|
public readonly rateLimit = {
|
||||||
|
requestsPerMinute: 100, // Conservative estimate
|
||||||
|
requestsPerDay: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
private logger = console;
|
||||||
|
private baseUrl = 'https://query1.finance.yahoo.com';
|
||||||
|
private userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
constructor(private config?: IProviderConfig) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified data fetching method
|
||||||
|
*/
|
||||||
|
public async fetchData(request: IStockDataRequest): Promise<IStockPrice | IStockPrice[]> {
|
||||||
|
switch (request.type) {
|
||||||
|
case 'current':
|
||||||
|
return this.fetchCurrentPrice(request);
|
||||||
|
case 'batch':
|
||||||
|
return this.fetchBatchCurrentPrices(request);
|
||||||
|
case 'historical':
|
||||||
|
throw new Error('Yahoo Finance provider does not support historical data. Use Marketstack provider instead.');
|
||||||
|
case 'intraday':
|
||||||
|
throw new Error('Yahoo Finance provider does not support intraday data yet. Use Marketstack provider instead.');
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request type: ${(request as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current price for a single ticker
|
||||||
|
*/
|
||||||
|
private async fetchCurrentPrice(request: IStockCurrentRequest): Promise<IStockPrice> {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/v8/finance/chart/${request.ticker}`;
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.headers({
|
||||||
|
'User-Agent': this.userAgent
|
||||||
|
})
|
||||||
|
.timeout(this.config?.timeout || 10000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
|
if (!responseData?.chart?.result?.[0]) {
|
||||||
|
throw new Error(`No data found for ticker ${request.ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = responseData.chart.result[0];
|
||||||
|
const meta = data.meta;
|
||||||
|
|
||||||
|
if (!meta.regularMarketPrice) {
|
||||||
|
throw new Error(`No price data available for ${request.ticker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockPrice: IStockPrice = {
|
||||||
|
ticker: request.ticker.toUpperCase(),
|
||||||
|
price: meta.regularMarketPrice,
|
||||||
|
currency: meta.currency || 'USD',
|
||||||
|
change: meta.regularMarketPrice - meta.previousClose,
|
||||||
|
changePercent: ((meta.regularMarketPrice - meta.previousClose) / meta.previousClose) * 100,
|
||||||
|
previousClose: meta.previousClose,
|
||||||
|
timestamp: new Date(meta.regularMarketTime * 1000),
|
||||||
|
provider: this.name,
|
||||||
|
marketState: this.determineMarketState(meta),
|
||||||
|
exchange: meta.exchange,
|
||||||
|
exchangeName: meta.exchangeName,
|
||||||
|
dataType: 'live', // Yahoo provides real-time/near real-time data
|
||||||
|
fetchedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
return stockPrice;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch price for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`Yahoo Finance: Failed to fetch price for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch batch current prices
|
||||||
|
*/
|
||||||
|
private async fetchBatchCurrentPrices(request: IStockBatchCurrentRequest): Promise<IStockPrice[]> {
|
||||||
|
try {
|
||||||
|
const symbols = request.tickers.join(',');
|
||||||
|
const url = `${this.baseUrl}/v8/finance/spark?symbols=${symbols}&range=1d&interval=5m`;
|
||||||
|
|
||||||
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.headers({
|
||||||
|
'User-Agent': this.userAgent
|
||||||
|
})
|
||||||
|
.timeout(this.config?.timeout || 15000)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const responseData = await response.json() as any;
|
||||||
|
const prices: IStockPrice[] = [];
|
||||||
|
|
||||||
|
for (const [ticker, data] of Object.entries(responseData)) {
|
||||||
|
if (!data || typeof data !== 'object') continue;
|
||||||
|
|
||||||
|
const sparkData = data as any;
|
||||||
|
if (!sparkData.previousClose || !sparkData.close?.length) {
|
||||||
|
console.warn(`Incomplete data for ${ticker}, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrice = sparkData.close[sparkData.close.length - 1];
|
||||||
|
const timestamp = sparkData.timestamp?.[sparkData.timestamp.length - 1];
|
||||||
|
|
||||||
|
prices.push({
|
||||||
|
ticker: ticker.toUpperCase(),
|
||||||
|
price: currentPrice,
|
||||||
|
currency: sparkData.currency || 'USD',
|
||||||
|
change: currentPrice - sparkData.previousClose,
|
||||||
|
changePercent: ((currentPrice - sparkData.previousClose) / sparkData.previousClose) * 100,
|
||||||
|
previousClose: sparkData.previousClose,
|
||||||
|
timestamp: timestamp ? new Date(timestamp * 1000) : new Date(),
|
||||||
|
provider: this.name,
|
||||||
|
marketState: sparkData.marketState || 'REGULAR',
|
||||||
|
exchange: sparkData.exchange,
|
||||||
|
exchangeName: sparkData.exchangeName,
|
||||||
|
dataType: 'live', // Yahoo provides real-time/near real-time data
|
||||||
|
fetchedAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
throw new Error('No valid price data received from batch request');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch batch prices:`, error);
|
||||||
|
throw new Error(`Yahoo Finance: Failed to fetch batch prices: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Test with a well-known ticker
|
||||||
|
await this.fetchData({ type: 'current', ticker: 'AAPL' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Yahoo Finance provider is not available:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public supportsMarket(market: string): boolean {
|
||||||
|
// Yahoo Finance supports most major markets
|
||||||
|
const supportedMarkets = ['US', 'UK', 'DE', 'FR', 'JP', 'CN', 'HK', 'AU', 'CA'];
|
||||||
|
return supportedMarkets.includes(market.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
public supportsTicker(ticker: string): boolean {
|
||||||
|
// Basic validation - Yahoo supports most tickers
|
||||||
|
return /^[A-Z0-9\.\-]{1,10}$/.test(ticker.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineMarketState(meta: any): 'PRE' | 'REGULAR' | 'POST' | 'CLOSED' {
|
||||||
|
const marketState = meta.marketState?.toUpperCase();
|
||||||
|
|
||||||
|
switch (marketState) {
|
||||||
|
case 'PRE':
|
||||||
|
return 'PRE';
|
||||||
|
case 'POST':
|
||||||
|
return 'POST';
|
||||||
|
case 'REGULAR':
|
||||||
|
return 'REGULAR';
|
||||||
|
default:
|
||||||
|
// Check if market is currently open based on timestamps
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const regularMarketTime = meta.regularMarketTime;
|
||||||
|
const timeDiff = now - regularMarketTime;
|
||||||
|
|
||||||
|
// If last update was more than 1 hour ago, market is likely closed
|
||||||
|
return timeDiff > 3600 ? 'CLOSED' : 'REGULAR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user