Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ae2bd737 | |||
| c806524e0c | |||
| fea83153ba | |||
| 4716ef03ba | |||
| 3b76de0831 | |||
| e94a6f8d5b | |||
| 1b1324d0f9 | |||
| 71a5a32198 |
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"
|
||||||
38
changelog.md
38
changelog.md
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2025-04-09 - 1.5.3 - fix(test)
|
||||||
Await file writes in Handelsregister tests to ensure all downloads complete before test end
|
Await file writes in Handelsregister tests to ensure all downloads complete before test end
|
||||||
|
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@fin.cx/opendata",
|
"name": "@fin.cx/opendata",
|
||||||
"version": "1.5.4",
|
"version": "2.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"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.",
|
"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",
|
||||||
@@ -14,29 +14,29 @@
|
|||||||
"buildDocs": "(tsdoc)"
|
"buildDocs": "(tsdoc)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^2.6.8",
|
||||||
"@git.zone/tsbundle": "^2.5.1",
|
"@git.zone/tsbundle": "^2.5.1",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^2.3.1",
|
"@git.zone/tstest": "^2.4.2",
|
||||||
"@types/node": "^22.14.0"
|
"@types/node": "^22.14.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartarchive": "^4.0.39",
|
"@push.rocks/smartarchive": "^4.2.2",
|
||||||
"@push.rocks/smartarray": "^1.1.0",
|
"@push.rocks/smartarray": "^1.1.0",
|
||||||
"@push.rocks/smartbrowser": "^2.0.8",
|
"@push.rocks/smartbrowser": "^2.0.8",
|
||||||
"@push.rocks/smartdata": "^5.15.1",
|
"@push.rocks/smartdata": "^5.16.4",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smartfile": "^11.2.5",
|
"@push.rocks/smartfile": "^11.2.7",
|
||||||
"@push.rocks/smartlog": "^3.1.8",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartpath": "^5.0.18",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.1.0",
|
"@push.rocks/smartrequest": "^4.3.1",
|
||||||
"@push.rocks/smartstream": "^3.2.5",
|
"@push.rocks/smartstream": "^3.2.5",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartxml": "^1.1.1",
|
"@push.rocks/smartxml": "^1.1.1",
|
||||||
"@tsclass/tsclass": "^9.2.0"
|
"@tsclass/tsclass": "^9.3.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
4156
pnpm-lock.yaml
generated
4156
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,24 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/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';
|
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;
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +40,7 @@ tap.test('should get the data for a specific company', async () => {
|
|||||||
console.log(result);
|
console.log(result);
|
||||||
|
|
||||||
await Promise.all(result.files.map(async (file) => {
|
await Promise.all(result.files.map(async (file) => {
|
||||||
await file.writeToDir('./.nogit/testoutput');
|
await file.writeToDir(testOutputDir);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
305
test/test.marketstack.node.ts
Normal file
305
test/test.marketstack.node.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
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 price');
|
||||||
|
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 fetchPrice directly
|
||||||
|
const price = await marketstackProvider.fetchPrice({ ticker: 'MSFT' });
|
||||||
|
expect(price.ticker).toEqual('MSFT');
|
||||||
|
expect(price.provider).toEqual('Marketstack');
|
||||||
|
expect(price.price).toBeGreaterThan(0);
|
||||||
|
console.log(` ✓ fetchPrice() for MSFT: $${price.price}`);
|
||||||
|
|
||||||
|
// Test fetchPrices directly
|
||||||
|
const prices = await marketstackProvider.fetchPrices({
|
||||||
|
tickers: ['AAPL', 'GOOGL']
|
||||||
|
});
|
||||||
|
expect(prices.length).toBeGreaterThan(0);
|
||||||
|
console.log(` ✓ fetchPrices() 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.fetchPrices({
|
||||||
|
tickers: sampleTickers.map(t => t.ticker)
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@fin.cx/opendata',
|
name: '@fin.cx/opendata',
|
||||||
version: '1.5.3',
|
version: '2.0.0',
|
||||||
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.'
|
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,7 +1,6 @@
|
|||||||
import type { BusinessRecord } from './classes.businessrecord.js';
|
import type { BusinessRecord } from './classes.businessrecord.js';
|
||||||
import type { OpenData } from './classes.main.opendata.js';
|
import type { OpenData } from './classes.main.opendata.js';
|
||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import * as paths from './paths.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the HandlesRegister exposed as a class
|
* the HandlesRegister exposed as a class
|
||||||
@@ -9,13 +8,16 @@ import * as paths from './paths.js';
|
|||||||
export class HandelsRegister {
|
export class HandelsRegister {
|
||||||
private openDataRef: OpenData;
|
private openDataRef: OpenData;
|
||||||
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
private asyncExecutionStack = new plugins.lik.AsyncExecutionStack();
|
||||||
private uniqueDowloadFolder = plugins.path.join(paths.downloadDir, plugins.smartunique.uniSimple());
|
private downloadDir: string;
|
||||||
|
private uniqueDowloadFolder: string;
|
||||||
|
|
||||||
// Puppeteer wrapper instance
|
// Puppeteer wrapper instance
|
||||||
public smartbrowserInstance = new plugins.smartbrowser.SmartBrowser();
|
public smartbrowserInstance = new plugins.smartbrowser.SmartBrowser();
|
||||||
|
|
||||||
constructor(openDataRef: OpenData) {
|
constructor(openDataRef: OpenData, downloadDirArg: string) {
|
||||||
this.openDataRef = openDataRef;
|
this.openDataRef = openDataRef;
|
||||||
|
this.downloadDir = downloadDirArg;
|
||||||
|
this.uniqueDowloadFolder = plugins.path.join(this.downloadDir, plugins.smartunique.uniSimple());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
@@ -76,7 +78,7 @@ export class HandelsRegister {
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
await pageArg.screenshot({ path: paths.downloadDir + '/error.png' });
|
await pageArg.screenshot({ path: this.downloadDir + '/error.png' });
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import * as paths from './paths.js';
|
|
||||||
import type { OpenData } from './classes.main.opendata.js';
|
import type { OpenData } from './classes.main.opendata.js';
|
||||||
|
|
||||||
export type SeedEntryType = {
|
export type SeedEntryType = {
|
||||||
@@ -41,8 +40,11 @@ export type SeedEntryType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class JsonlDataProcessor<T> {
|
export class JsonlDataProcessor<T> {
|
||||||
|
private germanBusinessDataDir: string;
|
||||||
public forEachFunction: (entryArg: T) => Promise<void>;
|
public forEachFunction: (entryArg: T) => Promise<void>;
|
||||||
constructor(forEachFunctionArg: typeof this.forEachFunction) {
|
|
||||||
|
constructor(germanBusinessDataDirArg: string, forEachFunctionArg: typeof this.forEachFunction) {
|
||||||
|
this.germanBusinessDataDir = germanBusinessDataDirArg;
|
||||||
this.forEachFunction = forEachFunctionArg;
|
this.forEachFunction = forEachFunctionArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +53,9 @@ export class JsonlDataProcessor<T> {
|
|||||||
dataUrlArg = 'https://daten.offeneregister.de/de_companies_ocdata.jsonl.bz2'
|
dataUrlArg = 'https://daten.offeneregister.de/de_companies_ocdata.jsonl.bz2'
|
||||||
) {
|
) {
|
||||||
const done = plugins.smartpromise.defer();
|
const done = plugins.smartpromise.defer();
|
||||||
const dataExists = await plugins.smartfile.fs.isDirectory(paths.germanBusinessDataDir);
|
const dataExists = await plugins.smartfile.fs.isDirectory(this.germanBusinessDataDir);
|
||||||
if (!dataExists) {
|
if (!dataExists) {
|
||||||
await plugins.smartfile.fs.ensureDir(paths.germanBusinessDataDir);
|
await plugins.smartfile.fs.ensureDir(this.germanBusinessDataDir);
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,39 @@ 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 {
|
||||||
public db: plugins.smartdata.SmartdataDb;
|
public db: plugins.smartdata.SmartdataDb;
|
||||||
private serviceQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir);
|
private serviceQenv: plugins.qenv.Qenv;
|
||||||
|
private config: IOpenDataConfig;
|
||||||
|
|
||||||
public jsonLDataProcessor: JsonlDataProcessor<SeedEntryType>;
|
public jsonLDataProcessor: JsonlDataProcessor<SeedEntryType>;
|
||||||
public handelsregister: HandelsRegister;
|
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'),
|
||||||
@@ -21,18 +44,21 @@ 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.jsonLDataProcessor = new JsonlDataProcessor(async (entryArg) => {
|
this.jsonLDataProcessor = new JsonlDataProcessor(
|
||||||
const businessRecord = new this.CBusinessRecord();
|
this.config.germanBusinessDataDir,
|
||||||
businessRecord.id = await this.CBusinessRecord.getNewId();
|
async (entryArg) => {
|
||||||
businessRecord.data.name = entryArg.name;
|
const businessRecord = new this.CBusinessRecord();
|
||||||
businessRecord.data.germanParsedRegistration = {
|
businessRecord.id = await this.CBusinessRecord.getNewId();
|
||||||
court: entryArg.all_attributes.registered_office,
|
businessRecord.data.name = entryArg.name;
|
||||||
number: entryArg.all_attributes._registerNummer,
|
businessRecord.data.germanParsedRegistration = {
|
||||||
type: entryArg.all_attributes._registerArt as 'HRA' | 'HRB',
|
court: entryArg.all_attributes.registered_office,
|
||||||
};
|
number: entryArg.all_attributes._registerNummer,
|
||||||
await businessRecord.save();
|
type: entryArg.all_attributes._registerArt as 'HRA' | 'HRB',
|
||||||
});
|
};
|
||||||
this.handelsregister = new HandelsRegister(this);
|
await businessRecord.save();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.handelsregister = new HandelsRegister(this, this.config.downloadDir);
|
||||||
await this.handelsregister.start();
|
await this.handelsregister.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +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 downloadDir = plugins.path.join(nogitDir, 'downloads');
|
|
||||||
plugins.smartfile.fs.ensureDirSync(downloadDir);
|
|
||||||
|
|
||||||
|
|
||||||
export const germanBusinessDataDir = plugins.path.join(nogitDir, 'germanbusinessdata');
|
|
||||||
@@ -7,3 +7,4 @@ export * from './classes.stockservice.js';
|
|||||||
|
|
||||||
// Export providers
|
// Export providers
|
||||||
export * from './providers/provider.yahoo.js';
|
export * from './providers/provider.yahoo.js';
|
||||||
|
export * from './providers/provider.marketstack.js';
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
|
||||||
export interface IStockPrice {
|
export interface IStockPrice {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -11,6 +13,10 @@ export interface IStockPrice {
|
|||||||
exchange?: string;
|
exchange?: string;
|
||||||
exchangeName?: string;
|
exchangeName?: string;
|
||||||
}
|
}
|
||||||
|
type CheckStockPrice = plugins.tsclass.typeFest.IsEqual<
|
||||||
|
IStockPrice,
|
||||||
|
plugins.tsclass.finance.IStockPrice
|
||||||
|
>;
|
||||||
|
|
||||||
export interface IStockPriceError {
|
export interface IStockPriceError {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
|
|||||||
200
ts/stocks/providers/provider.marketstack.ts
Normal file
200
ts/stocks/providers/provider.marketstack.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { IStockProvider, IProviderConfig } from '../interfaces/provider.js';
|
||||||
|
import type { IStockPrice, IStockQuoteRequest, IStockBatchQuoteRequest } from '../interfaces/stockprice.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketstack API v2 Provider
|
||||||
|
* Documentation: https://marketstack.com/documentation_v2
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - End-of-Day (EOD) stock prices
|
||||||
|
* - Supports 125,000+ tickers across 72+ exchanges worldwide
|
||||||
|
* - 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
|
||||||
|
*
|
||||||
|
* Note: This provider returns EOD data, not real-time prices
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest EOD price for a single ticker
|
||||||
|
*/
|
||||||
|
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/tickers/${request.ticker}/eod/latest?access_key=${this.apiKey}`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch price for ${request.ticker}:`, error);
|
||||||
|
throw new Error(`Marketstack: Failed to fetch price for ${request.ticker}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest EOD prices for multiple tickers
|
||||||
|
*/
|
||||||
|
public async fetchPrices(request: IStockBatchQuoteRequest): Promise<IStockPrice[]> {
|
||||||
|
try {
|
||||||
|
const symbols = request.tickers.join(',');
|
||||||
|
const url = `${this.baseUrl}/eod/latest?access_key=${this.apiKey}&symbols=${symbols}`;
|
||||||
|
|
||||||
|
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));
|
||||||
|
} 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 prices:`, error);
|
||||||
|
throw new Error(`Marketstack: Failed to fetch batch 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): 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;
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
return stockPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,15 @@ export class YahooFinanceProvider implements IStockProvider {
|
|||||||
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
public async fetchPrice(request: IStockQuoteRequest): Promise<IStockPrice> {
|
||||||
try {
|
try {
|
||||||
const url = `${this.baseUrl}/v8/finance/chart/${request.ticker}`;
|
const url = `${this.baseUrl}/v8/finance/chart/${request.ticker}`;
|
||||||
const response = await plugins.smartrequest.getJson(url, {
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
headers: {
|
.url(url)
|
||||||
|
.headers({
|
||||||
'User-Agent': this.userAgent
|
'User-Agent': this.userAgent
|
||||||
},
|
})
|
||||||
timeout: this.config?.timeout || 10000
|
.timeout(this.config?.timeout || 10000)
|
||||||
});
|
.get();
|
||||||
|
|
||||||
const responseData = response.body as any;
|
const responseData = await response.json() as any;
|
||||||
|
|
||||||
if (!responseData?.chart?.result?.[0]) {
|
if (!responseData?.chart?.result?.[0]) {
|
||||||
throw new Error(`No data found for ticker ${request.ticker}`);
|
throw new Error(`No data found for ticker ${request.ticker}`);
|
||||||
@@ -66,14 +67,15 @@ export class YahooFinanceProvider implements IStockProvider {
|
|||||||
const symbols = request.tickers.join(',');
|
const symbols = request.tickers.join(',');
|
||||||
const url = `${this.baseUrl}/v8/finance/spark?symbols=${symbols}&range=1d&interval=5m`;
|
const url = `${this.baseUrl}/v8/finance/spark?symbols=${symbols}&range=1d&interval=5m`;
|
||||||
|
|
||||||
const response = await plugins.smartrequest.getJson(url, {
|
const response = await plugins.smartrequest.SmartRequest.create()
|
||||||
headers: {
|
.url(url)
|
||||||
|
.headers({
|
||||||
'User-Agent': this.userAgent
|
'User-Agent': this.userAgent
|
||||||
},
|
})
|
||||||
timeout: this.config?.timeout || 15000
|
.timeout(this.config?.timeout || 15000)
|
||||||
});
|
.get();
|
||||||
|
|
||||||
const responseData = response.body as any;
|
const responseData = await response.json() as any;
|
||||||
const prices: IStockPrice[] = [];
|
const prices: IStockPrice[] = [];
|
||||||
|
|
||||||
for (const [ticker, data] of Object.entries(responseData)) {
|
for (const [ticker, data] of Object.entries(responseData)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user