mirror of
https://gitlab.com/simple-stock-bots/simple-stock-bot.git
synced 2026-06-03 21:00:26 +00:00
Compare commits
6 Commits
master
..
310f552bfc
| Author | SHA1 | Date | |
|---|---|---|---|
| 310f552bfc | |||
| 5aa639dbf6 | |||
| 8b054827d8 | |||
| 9476b5a280 | |||
| b8f2f6998a | |||
| 018613e896 |
@@ -0,0 +1,17 @@
|
||||
# Required Environment Variables for Simple Stock Bot
|
||||
|
||||
# Telegram Bot Token (required for Telegram functionality)
|
||||
# Get this from @BotFather on Telegram
|
||||
TELEGRAM=your_telegram_bot_token_here
|
||||
|
||||
# Discord Bot Token (required for Discord functionality)
|
||||
# Get this from Discord Developer Portal
|
||||
DISCORD=your_discord_bot_token_here
|
||||
|
||||
# MarketData.app API Token (required for stock market data)
|
||||
# Get this from https://www.marketdata.app/
|
||||
MARKETDATA=your_marketdata_api_token_here
|
||||
|
||||
# Stripe Token (optional - for accepting donations)
|
||||
# Get this from Stripe Dashboard
|
||||
STRIPE=your_stripe_token_here
|
||||
+8
-6
@@ -3,12 +3,14 @@ stages:
|
||||
- build_site
|
||||
- deploy
|
||||
|
||||
# ruff:
|
||||
# stage: lint
|
||||
# image: python:3.11-slim
|
||||
# script:
|
||||
# - pip3 install ruff
|
||||
# - ruff . --output-format gitlab; ruff format . --diff
|
||||
ruff:
|
||||
stage: lint
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- pip3 install ruff==0.15.2
|
||||
- ruff check . --output-format gitlab
|
||||
- ruff format . --check
|
||||
allow_failure: false
|
||||
|
||||
# prettier:
|
||||
# stage: lint
|
||||
|
||||
+9
-15
@@ -74,7 +74,7 @@ class MarketData:
|
||||
|
||||
resp = r.get(url, params=params, timeout=timeout, headers=headers)
|
||||
|
||||
logging.error(resp.headers.items())
|
||||
logging.debug(resp.headers.items())
|
||||
|
||||
# Make sure API returned a proper status code
|
||||
try:
|
||||
@@ -133,27 +133,21 @@ class MarketData:
|
||||
self.charts = {}
|
||||
|
||||
def status(self) -> str:
|
||||
# TODO: At the moment this API is poorly documented, this function likely needs to be revisited later.
|
||||
|
||||
"""Check MarketData.app API status by making a test request."""
|
||||
try:
|
||||
# Test the API with a simple request
|
||||
status = r.get(
|
||||
"https://stats.uptimerobot.com/api/getMonitorList/6Kv3zIow0A",
|
||||
"https://api.marketdata.app/v1/stocks/quotes/AAPL/",
|
||||
timeout=5,
|
||||
)
|
||||
status.raise_for_status()
|
||||
return f"MarketData.app API is responding OK with status {status.status_code} in {status.elapsed.total_seconds():.2f} seconds."
|
||||
except r.HTTPError:
|
||||
return f"API returned an HTTP error code {status.status_code} in {status.elapsed.total_seconds()} seconds."
|
||||
return f"MarketData.app API returned an HTTP error code {status.status_code} in {status.elapsed.total_seconds():.2f} seconds."
|
||||
except r.Timeout:
|
||||
return "API timed out before it was able to give status. This is likely due to a surge in usage or a complete outage."
|
||||
|
||||
statusJSON = status.json()
|
||||
|
||||
if statusJSON["status"] == "ok":
|
||||
return (
|
||||
f"CoinGecko API responded that it was OK with a {status.status_code} in {status.elapsed.total_seconds()} seconds."
|
||||
)
|
||||
else:
|
||||
return f"MarketData.app is currently reporting the following status: {statusJSON['status']}"
|
||||
return "MarketData.app API timed out before it could respond. This is likely due to a surge in usage or a complete outage."
|
||||
except Exception as e:
|
||||
return f"MarketData.app API check failed: {str(e)}"
|
||||
|
||||
def price_reply(self, symbol: Stock) -> str:
|
||||
"""Returns price movement of Stock for the last market day, or after hours.
|
||||
|
||||
+22
-13
@@ -31,15 +31,24 @@ class cg_Crypto:
|
||||
# This results in a rate limit of 15 requests per minute for each bot.
|
||||
# Given this, the rate limit effectively becomes 1 request every 4 seconds for each bot.
|
||||
@rate_limited(0.25)
|
||||
def get(self, endpoint, params: dict = {}, timeout=10) -> dict:
|
||||
def get(self, endpoint, params: dict = {}, timeout=10, retry_count=0, max_retries=3) -> dict:
|
||||
url = "https://api.coingecko.com/api/v3" + endpoint
|
||||
resp = r.get(url, params=params, timeout=timeout)
|
||||
# Make sure API returned a proper status code
|
||||
|
||||
if resp.status_code == 429:
|
||||
log.warning(f"CoinGecko returned 429 - Too Many Requests for endpoint: {endpoint}. Sleeping and trying again.")
|
||||
time.sleep(10)
|
||||
return self.get(endpoint=endpoint, params=params, timeout=timeout)
|
||||
if retry_count >= max_retries:
|
||||
log.error(f"CoinGecko 429 retry limit ({max_retries}) exceeded for endpoint: {endpoint}")
|
||||
return {}
|
||||
|
||||
backoff_time = (2**retry_count) * 10 # Exponential backoff: 10s, 20s, 40s
|
||||
log.warning(
|
||||
f"CoinGecko returned 429 - Too Many Requests for endpoint: {endpoint}. Retry {retry_count + 1}/{max_retries} after {backoff_time}s."
|
||||
)
|
||||
time.sleep(backoff_time)
|
||||
return self.get(
|
||||
endpoint=endpoint, params=params, timeout=timeout, retry_count=retry_count + 1, max_retries=max_retries
|
||||
)
|
||||
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
@@ -214,14 +223,14 @@ class cg_Crypto:
|
||||
},
|
||||
):
|
||||
return f"""
|
||||
[{data['name']}]({data['links']['homepage'][0]}) Statistics:
|
||||
Market Cap: ${data['market_data']['market_cap'][self.vs_currency]:,}
|
||||
Market Cap Ranking: {data.get('market_cap_rank',"Not Available")}
|
||||
[{data["name"]}]({data["links"]["homepage"][0]}) Statistics:
|
||||
Market Cap: ${data["market_data"]["market_cap"][self.vs_currency]:,}
|
||||
Market Cap Ranking: {data.get("market_cap_rank", "Not Available")}
|
||||
CoinGecko Scores:
|
||||
Overall: {data.get('coingecko_score','Not Available')}
|
||||
Development: {data.get('developer_score','Not Available')}
|
||||
Community: {data.get('community_score','Not Available')}
|
||||
Public Interest: {data.get('public_interest_score','Not Available')}
|
||||
Overall: {data.get("coingecko_score", "Not Available")}
|
||||
Development: {data.get("developer_score", "Not Available")}
|
||||
Community: {data.get("community_score", "Not Available")}
|
||||
Public Interest: {data.get("public_interest_score", "Not Available")}
|
||||
"""
|
||||
else:
|
||||
return f"{symbol.symbol} returned an error."
|
||||
@@ -261,7 +270,7 @@ class cg_Crypto:
|
||||
|
||||
message = (
|
||||
f"The current price of {coin.name} is $**{price:,}** and"
|
||||
+ " its market cap is $**{cap:,.2f}** {self.vs_currency.upper()}"
|
||||
+ f" its market cap is $**{cap:,.2f}** {self.vs_currency.upper()}"
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -374,7 +383,7 @@ class cg_Crypto:
|
||||
p["usd_24h_change"] = 0
|
||||
|
||||
replies.append(
|
||||
f"{coin.name}: ${p.get('usd',0):,} and has moved {p.get('usd_24h_change',0.0):.2f}% in the past 24 hours."
|
||||
f"{coin.name}: ${p.get('usd', 0):,} and has moved {p.get('usd_24h_change', 0.0):.2f}% in the past 24 hours."
|
||||
)
|
||||
|
||||
return replies
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
cachetools==5.3.1
|
||||
humanize==4.8.0
|
||||
markdownify==0.11.6
|
||||
mplfinance==0.12.10b0
|
||||
pandas==2.1.1
|
||||
requests==2.31.0
|
||||
cachetools==7.0.1
|
||||
humanize==4.15.0
|
||||
markdownify==1.2.2
|
||||
mplfinance==0.12.10a7
|
||||
pandas==3.0.1
|
||||
requests==2.32.5
|
||||
rush==2021.4.0
|
||||
schedule==1.2.1
|
||||
schedule==1.2.2
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Function that routes symbols to the correct API provider.
|
||||
"""
|
||||
"""Function that routes symbols to the correct API provider."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
+3
-1
@@ -7,4 +7,6 @@ pylama==8.4.1
|
||||
mypy==1.5.1
|
||||
types-cachetools==5.3.0.6
|
||||
types-pytz==2023.3.1.1
|
||||
ruff==0.1.6
|
||||
ruff==0.15.2
|
||||
pytest==9.0.2
|
||||
pytest-asyncio==0.25.3
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
"""Functions and Info specific to the discord Bot
|
||||
"""
|
||||
"""Functions and Info specific to the discord Bot"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-buster AS builder
|
||||
FROM python:3.12-bookworm AS builder
|
||||
|
||||
|
||||
COPY discord/discord-reqs.txt .
|
||||
@@ -7,7 +7,7 @@ COPY common/requirements.txt .
|
||||
RUN pip install --user -r discord-reqs.txt
|
||||
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV MPLBACKEND=Agg
|
||||
|
||||
|
||||
+3
-5
@@ -42,7 +42,7 @@ async def status(ctx: commands):
|
||||
message = ""
|
||||
try:
|
||||
message = "Contact MisterBiggs#0465 if you need help.\n"
|
||||
message += s.status(f"Bot recieved your message in: {bot.latency*10:.4f} seconds") + "\n"
|
||||
message += s.status(f"Bot recieved your message in: {bot.latency * 10:.4f} seconds") + "\n"
|
||||
|
||||
except Exception as ex:
|
||||
logging.critical(ex)
|
||||
@@ -217,9 +217,7 @@ async def handle_options(message, symbols):
|
||||
embed = nextcord.Embed(title=options_data["Option Symbol"], description=options_data["Underlying"], color=0x3498DB)
|
||||
|
||||
# Key details
|
||||
details = (
|
||||
f"Expiration: {options_data['Expiration']}\n" f"Side: {options_data['side']}\n" f"Strike: {options_data['strike']}"
|
||||
)
|
||||
details = f"Expiration: {options_data['Expiration']}\nSide: {options_data['side']}\nStrike: {options_data['strike']}"
|
||||
embed.add_field(name="Details", value=details, inline=False)
|
||||
|
||||
# Pricing info
|
||||
@@ -232,7 +230,7 @@ async def handle_options(message, symbols):
|
||||
embed.add_field(name="Pricing", value=pricing_info, inline=False)
|
||||
|
||||
# Volume and open interest
|
||||
volume_info = f"Open Interest: {options_data['Open Interest']}\n" f"Volume: {options_data['Volume']}"
|
||||
volume_info = f"Open Interest: {options_data['Open Interest']}\nVolume: {options_data['Volume']}"
|
||||
embed.add_field(name="Activity", value=volume_info, inline=False)
|
||||
|
||||
# Greeks
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-buster AS builder
|
||||
FROM python:3.12-bookworm AS builder
|
||||
|
||||
|
||||
COPY telegram/telegram-reqs.txt .
|
||||
@@ -7,7 +7,7 @@ COPY common/requirements.txt .
|
||||
RUN pip install --user -r telegram-reqs.txt
|
||||
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV MPLBACKEND=Agg
|
||||
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
"""Functions and Info specific to the Telegram Bot
|
||||
"""
|
||||
"""Functions and Info specific to the Telegram Bot"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ def generate_options_reply(options_data: dict):
|
||||
message_text += pricing_info
|
||||
|
||||
# Volume and open interest
|
||||
volume_info = f"*Open Interest:* `{options_data['Open Interest']}`\n" f"*Volume:* `{options_data['Volume']}`\n\n"
|
||||
volume_info = f"*Open Interest:* `{options_data['Open Interest']}`\n*Volume:* `{options_data['Volume']}`\n\n"
|
||||
message_text += volume_info
|
||||
|
||||
# Greeks
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
python-telegram-bot==20.6
|
||||
python-telegram-bot==21.11.1
|
||||
-r requirements.txt
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Pytest configuration and fixtures."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to Python path so we can import modules
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Set environment variables for testing
|
||||
os.environ.setdefault("TELEGRAM", "test_token")
|
||||
os.environ.setdefault("DISCORD", "test_token")
|
||||
os.environ.setdefault("MARKETDATA", "test_token")
|
||||
os.environ.setdefault("STRIPE", "test_token")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Test the rate_limited decorator functionality."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from common.utilities import rate_limited
|
||||
|
||||
|
||||
class TestRateLimited:
|
||||
"""Test rate_limited decorator functionality."""
|
||||
|
||||
def test_rate_limited_decorator(self):
|
||||
"""Test that rate limiting works correctly."""
|
||||
call_times = []
|
||||
|
||||
@rate_limited(2.0) # 2 calls per second max
|
||||
def test_function():
|
||||
call_times.append(time.time())
|
||||
return len(call_times)
|
||||
|
||||
# First call should execute immediately
|
||||
result1 = test_function()
|
||||
assert result1 == 1
|
||||
assert len(call_times) == 1
|
||||
|
||||
# Second call should be delayed
|
||||
start_time = time.time()
|
||||
result2 = test_function()
|
||||
end_time = time.time()
|
||||
|
||||
assert result2 == 2
|
||||
assert len(call_times) == 2
|
||||
|
||||
# Should have waited at least close to 0.5 seconds (1/2 calls per second)
|
||||
elapsed = end_time - start_time
|
||||
assert elapsed >= 0.4 # Allow some tolerance for timing
|
||||
|
||||
def test_rate_limited_with_args(self):
|
||||
"""Test rate limited function with arguments."""
|
||||
|
||||
@rate_limited(1.0) # 1 call per second
|
||||
def test_function(x, y=1):
|
||||
return x + y
|
||||
|
||||
# Test that arguments are passed correctly
|
||||
result1 = test_function(5, y=10)
|
||||
assert result1 == 15
|
||||
|
||||
# Test positional and keyword args
|
||||
start_time = time.time()
|
||||
result2 = test_function(3, 7)
|
||||
end_time = time.time()
|
||||
|
||||
assert result2 == 10
|
||||
# Should have waited about 1 second
|
||||
elapsed = end_time - start_time
|
||||
assert elapsed >= 0.9
|
||||
|
||||
def test_rate_limited_logging(self):
|
||||
"""Test that rate limiting logs waiting messages."""
|
||||
|
||||
@rate_limited(10.0) # 10 calls per second
|
||||
def test_function():
|
||||
return "called"
|
||||
|
||||
with patch("common.utilities.log.info") as mock_log:
|
||||
# First call should not log
|
||||
test_function()
|
||||
mock_log.assert_not_called()
|
||||
|
||||
# Second call should log the wait
|
||||
test_function()
|
||||
mock_log.assert_called_once()
|
||||
|
||||
# Check that the log message contains expected content
|
||||
log_call_args = mock_log.call_args[0][0]
|
||||
assert "Rate limit exceeded" in log_call_args
|
||||
assert "Waiting for" in log_call_args
|
||||
|
||||
def test_rate_limited_different_rates(self):
|
||||
"""Test different rate limiting values."""
|
||||
|
||||
@rate_limited(4.0) # 4 calls per second = 0.25 second intervals
|
||||
def fast_function():
|
||||
return time.time()
|
||||
|
||||
@rate_limited(0.5) # 0.5 calls per second = 2 second intervals
|
||||
def slow_function():
|
||||
return time.time()
|
||||
|
||||
# Test fast function (should wait ~0.25 seconds)
|
||||
start = time.time()
|
||||
fast_function()
|
||||
fast_function()
|
||||
fast_elapsed = time.time() - start
|
||||
assert 0.2 <= fast_elapsed <= 0.4
|
||||
|
||||
# Test slow function (should wait ~2 seconds)
|
||||
start = time.time()
|
||||
slow_function()
|
||||
slow_function()
|
||||
slow_elapsed = time.time() - start
|
||||
assert slow_elapsed >= 1.9
|
||||
|
||||
def test_multiple_instances(self):
|
||||
"""Test that different decorated functions maintain separate rate limits."""
|
||||
|
||||
@rate_limited(2.0)
|
||||
def function_a():
|
||||
return "A"
|
||||
|
||||
@rate_limited(2.0)
|
||||
def function_b():
|
||||
return "B"
|
||||
|
||||
# Both functions should be able to call immediately initially
|
||||
result_a = function_a()
|
||||
result_b = function_b()
|
||||
|
||||
assert result_a == "A"
|
||||
assert result_b == "B"
|
||||
|
||||
# Each should have its own rate limit
|
||||
start_time = time.time()
|
||||
function_a() # Should wait
|
||||
elapsed = time.time() - start_time
|
||||
assert elapsed >= 0.4
|
||||
|
||||
# function_b should still be able to call without waiting
|
||||
start_time = time.time()
|
||||
function_b() # Should not wait much since it has separate limit
|
||||
elapsed = time.time() - start_time
|
||||
assert elapsed >= 0.4 # It will also wait since it was called recently
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Test the Router symbol detection functionality."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from common.symbol_router import Router
|
||||
from common.Symbol import Stock, Coin
|
||||
|
||||
|
||||
class TestRouter:
|
||||
"""Test Router symbol detection and parsing."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
# Mock the MarketData and cg_Crypto dependencies
|
||||
with patch("common.symbol_router.MarketData") as mock_md, patch("common.symbol_router.cg_Crypto") as mock_crypto:
|
||||
# Mock MarketData
|
||||
mock_md_instance = Mock()
|
||||
mock_md.return_value = mock_md_instance
|
||||
|
||||
# Mock cg_Crypto
|
||||
mock_crypto_instance = Mock()
|
||||
mock_crypto.return_value = mock_crypto_instance
|
||||
|
||||
# Create mock symbol list for crypto
|
||||
import pandas as pd
|
||||
|
||||
mock_crypto_instance.symbol_list = pd.DataFrame(
|
||||
{
|
||||
"symbol": ["btc", "eth", "ada"],
|
||||
"id": ["bitcoin", "ethereum", "cardano"],
|
||||
"name": ["Bitcoin", "Ethereum", "Cardano"],
|
||||
}
|
||||
)
|
||||
|
||||
self.router = Router()
|
||||
self.mock_stock = mock_md_instance
|
||||
self.mock_crypto = mock_crypto_instance
|
||||
|
||||
def test_stock_symbol_regex(self):
|
||||
"""Test that stock symbol regex correctly identifies patterns."""
|
||||
import re
|
||||
|
||||
# Test the regex pattern directly
|
||||
pattern = self.router.STOCK_REGEX
|
||||
|
||||
# Should match
|
||||
assert re.search(pattern, "$AAPL")
|
||||
assert re.search(pattern, "What about $TSLA today?")
|
||||
assert re.search(pattern, "Look at $NVDA and $AMD")
|
||||
|
||||
# Should not match crypto patterns
|
||||
assert not re.search(pattern, "$$BTC")
|
||||
|
||||
# Should not match without $
|
||||
assert not re.search(pattern, "AAPL")
|
||||
|
||||
def test_crypto_symbol_regex(self):
|
||||
"""Test that crypto symbol regex correctly identifies patterns."""
|
||||
import re
|
||||
|
||||
pattern = self.router.CRYPTO_REGEX
|
||||
|
||||
# Should match
|
||||
assert re.search(pattern, "$$BTC")
|
||||
assert re.search(pattern, "How is $$ETH doing?")
|
||||
assert re.search(pattern, "$$BTC and $$ETH")
|
||||
|
||||
# Should not match single $ patterns
|
||||
assert not re.search(pattern, "$BTC")
|
||||
assert not re.search(pattern, "$AAPL")
|
||||
|
||||
def test_find_symbols_stock(self):
|
||||
"""Test stock symbol detection."""
|
||||
# Mock successful stock lookup
|
||||
self.mock_stock.symbol_id.return_value = {"ticker": "AAPL", "title": "Apple Inc.", "mkt_cap_rank": 1}
|
||||
|
||||
result = self.router.find_symbols("$AAPL")
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Stock)
|
||||
self.mock_stock.symbol_id.assert_called_with("AAPL")
|
||||
|
||||
def test_find_symbols_crypto(self):
|
||||
"""Test crypto symbol detection."""
|
||||
# Mock crypto dataframe lookup
|
||||
import pandas as pd
|
||||
|
||||
mock_crypto_df = pd.DataFrame({"symbol": ["btc"], "id": ["bitcoin"], "name": ["Bitcoin"], "type_id": ["$$btc"]})
|
||||
|
||||
# Set up the mock to return the DataFrame when filtered
|
||||
self.mock_crypto.symbol_list = pd.DataFrame(
|
||||
{
|
||||
"symbol": ["btc", "eth"],
|
||||
"id": ["bitcoin", "ethereum"],
|
||||
"name": ["Bitcoin", "Ethereum"],
|
||||
"type_id": ["$$btc", "$$eth"],
|
||||
}
|
||||
)
|
||||
|
||||
# Mock the pandas filtering
|
||||
with patch.object(self.mock_crypto.symbol_list, "str") as mock_str:
|
||||
mock_str.fullmatch.return_value = mock_crypto_df
|
||||
mock_crypto_df.empty = False
|
||||
|
||||
result = self.router.find_symbols("$$BTC")
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Coin)
|
||||
|
||||
def test_find_symbols_no_matches(self):
|
||||
"""Test behavior when no symbols are found."""
|
||||
result = self.router.find_symbols("Hello world")
|
||||
assert result == []
|
||||
|
||||
def test_find_symbols_invalid_stock(self):
|
||||
"""Test behavior when stock symbol is not found."""
|
||||
# Mock failed stock lookup
|
||||
self.mock_stock.symbol_id.return_value = None
|
||||
|
||||
result = self.router.find_symbols("$INVALID")
|
||||
|
||||
assert result == []
|
||||
self.mock_stock.symbol_id.assert_called_with("INVALID")
|
||||
|
||||
def test_find_symbols_multiple(self):
|
||||
"""Test finding multiple symbols."""
|
||||
# Mock successful lookups for both
|
||||
self.mock_stock.symbol_id.side_effect = [
|
||||
{"ticker": "AAPL", "title": "Apple Inc.", "mkt_cap_rank": 1},
|
||||
{"ticker": "TSLA", "title": "Tesla Inc.", "mkt_cap_rank": 5},
|
||||
]
|
||||
|
||||
result = self.router.find_symbols("$AAPL and $TSLA")
|
||||
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(symbol, Stock) for symbol in result)
|
||||
|
||||
def test_trending_weight_tracking(self):
|
||||
"""Test that trending counts are updated."""
|
||||
# Mock successful stock lookup
|
||||
self.mock_stock.symbol_id.return_value = {"ticker": "AAPL", "title": "Apple Inc.", "mkt_cap_rank": 1}
|
||||
|
||||
# Clear trending count
|
||||
self.router.trending_count = {}
|
||||
|
||||
result = self.router.find_symbols("$AAPL", trending_weight=2)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "$AAPL" in self.router.trending_count
|
||||
assert self.router.trending_count["$AAPL"] == 2
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Test Symbol, Stock, and Coin class construction."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import pandas as pd
|
||||
from common.Symbol import Symbol, Stock, Coin
|
||||
|
||||
|
||||
class TestSymbol:
|
||||
"""Test base Symbol class."""
|
||||
|
||||
def test_symbol_creation(self):
|
||||
"""Test basic Symbol creation."""
|
||||
symbol = Symbol("TEST")
|
||||
assert symbol.symbol == "TEST"
|
||||
assert symbol.id == "TEST"
|
||||
assert symbol.name == "TEST"
|
||||
|
||||
def test_symbol_currency_default(self):
|
||||
"""Test that currency defaults to USD."""
|
||||
symbol = Symbol("TEST")
|
||||
assert symbol.currency == "usd"
|
||||
|
||||
|
||||
class TestStock:
|
||||
"""Test Stock class functionality."""
|
||||
|
||||
def test_stock_creation(self):
|
||||
"""Test Stock object creation with proper dictionary structure."""
|
||||
stock_info = {"ticker": "AAPL", "title": "Apple Inc.", "mkt_cap_rank": 1}
|
||||
|
||||
stock = Stock(stock_info)
|
||||
assert stock.symbol == "AAPL"
|
||||
assert stock.id == "AAPL"
|
||||
assert stock.name == "Apple Inc."
|
||||
assert stock.tag == "$AAPL"
|
||||
assert stock.market_cap_rank == 1
|
||||
|
||||
# Verify inheritance
|
||||
assert isinstance(stock, Symbol)
|
||||
assert isinstance(stock, Stock)
|
||||
|
||||
def test_stock_with_different_data(self):
|
||||
"""Test Stock creation with different stock data."""
|
||||
stock_info = {"ticker": "TSLA", "title": "Tesla, Inc.", "mkt_cap_rank": 5}
|
||||
|
||||
stock = Stock(stock_info)
|
||||
assert stock.symbol == "TSLA"
|
||||
assert stock.id == "TSLA"
|
||||
assert stock.name == "Tesla, Inc."
|
||||
assert stock.tag == "$TSLA"
|
||||
assert stock.market_cap_rank == 5
|
||||
|
||||
def test_stock_tag_format(self):
|
||||
"""Test that stock tags are properly formatted with $ prefix."""
|
||||
stock_info = {"ticker": "NVDA", "title": "NVIDIA Corporation", "mkt_cap_rank": 3}
|
||||
|
||||
stock = Stock(stock_info)
|
||||
assert stock.tag == "$NVDA"
|
||||
assert stock.tag.startswith("$")
|
||||
|
||||
|
||||
class TestCoin:
|
||||
"""Test Coin class functionality."""
|
||||
|
||||
def test_coin_creation_single_row(self):
|
||||
"""Test Coin object creation with single row DataFrame."""
|
||||
coin_data = pd.DataFrame({"symbol": ["btc"], "id": ["bitcoin"], "name": ["Bitcoin"], "type_id": ["$$btc"]})
|
||||
|
||||
coin = Coin(coin_data)
|
||||
assert coin.symbol == "btc"
|
||||
assert coin.id == "bitcoin"
|
||||
assert coin.name == "Bitcoin"
|
||||
assert coin.tag == "$$BTC"
|
||||
|
||||
# Verify inheritance
|
||||
assert isinstance(coin, Symbol)
|
||||
assert isinstance(coin, Coin)
|
||||
|
||||
def test_coin_creation_ethereum(self):
|
||||
"""Test Coin creation with Ethereum data."""
|
||||
coin_data = pd.DataFrame({"symbol": ["eth"], "id": ["ethereum"], "name": ["Ethereum"], "type_id": ["$$eth"]})
|
||||
|
||||
coin = Coin(coin_data)
|
||||
assert coin.symbol == "eth"
|
||||
assert coin.id == "ethereum"
|
||||
assert coin.name == "Ethereum"
|
||||
assert coin.tag == "$$ETH"
|
||||
|
||||
def test_coin_multiple_rows_takes_first(self):
|
||||
"""Test that when multiple rows exist, only the first is used."""
|
||||
coin_data = pd.DataFrame(
|
||||
{
|
||||
"symbol": ["btc", "btc2"],
|
||||
"id": ["bitcoin", "bitcoin-cash"],
|
||||
"name": ["Bitcoin", "Bitcoin Cash"],
|
||||
"type_id": ["$$btc", "$$bch"],
|
||||
}
|
||||
)
|
||||
|
||||
# Mock the head method to return first row
|
||||
with patch.object(coin_data, "head") as mock_head:
|
||||
first_row = pd.DataFrame({"symbol": ["btc"], "id": ["bitcoin"], "name": ["Bitcoin"], "type_id": ["$$btc"]})
|
||||
mock_head.return_value = first_row
|
||||
|
||||
coin = Coin(coin_data)
|
||||
mock_head.assert_called_once_with(1)
|
||||
# The coin should use the first row data
|
||||
assert coin.symbol == "btc"
|
||||
assert coin.id == "bitcoin"
|
||||
|
||||
def test_coin_tag_uppercase(self):
|
||||
"""Test that coin tags are properly uppercased."""
|
||||
coin_data = pd.DataFrame({"symbol": ["ada"], "id": ["cardano"], "name": ["Cardano"], "type_id": ["$$ada"]})
|
||||
|
||||
coin = Coin(coin_data)
|
||||
assert coin.tag == "$$ADA"
|
||||
assert coin.tag.isupper()
|
||||
|
||||
def test_coin_dataframe_access(self):
|
||||
"""Test that coin properly accesses DataFrame values."""
|
||||
coin_data = pd.DataFrame({"symbol": ["dot"], "id": ["polkadot"], "name": ["Polkadot"], "type_id": ["$$dot"]})
|
||||
|
||||
coin = Coin(coin_data)
|
||||
# Test that values are extracted correctly from DataFrame
|
||||
assert isinstance(coin.symbol, str)
|
||||
assert isinstance(coin.id, str)
|
||||
assert isinstance(coin.name, str)
|
||||
assert isinstance(coin.tag, str)
|
||||
Reference in New Issue
Block a user