Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def prepare_watchlist(service, user, action=None, ticker=None):
ticker_link = util.finance_link(ticker, ticker, service)
duplicate = False
transformed = False
watchlist = util.json_load('finbot_watchlist.json', persist=True)
watchlist = util.json_load('finbot_watchlist.json', persist=True) or [] # absent until the first add
if action == 'add':
if ticker in watchlist:
duplicate = True
Expand Down Expand Up @@ -301,6 +301,7 @@ def last_col(e):
link = util.finance_link(ticker, market_data[ticker]['profile_exchange'], service)
flag = util.flag_from_ticker(ticker)
payload_staging.append(f"{flag} {title} ({link}) mkt cap: {market_cap_readable} {market_cap}")
payload = ['No market cap data found']
if payload_staging:
payload_staging.sort(key=last_col)
if action == 'top':
Expand Down
10 changes: 9 additions & 1 deletion lib/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,14 @@ def hbar_chart(rows, title, subtitle='', value_fmt=None, axis_fmt=None, threshol
canvas.draw()
label_width = [t.get_window_extent(renderer).width for t in base_labels]
value_width = [t.get_window_extent(renderer).width for t in value_labels]
if xlim: # zoomed charts put the labels in the left margin: shrink the text until the widest label fits inside the image
room = left * fig.get_figwidth() * dpi - 30 # also leaves the 4pt gap before the bar base
if max(label_width) > room:
for t in base_labels + value_labels:
t.set_fontsize(t.get_fontsize() * room / max(label_width))
canvas.draw()
label_width = [t.get_window_extent(renderer).width for t in base_labels]
value_width = [t.get_window_extent(renderer).width for t in value_labels]
width = ax.get_window_extent(renderer).width
lo, hi = lo0, hi0
inside = [True] * len(real)
Expand Down Expand Up @@ -828,7 +836,7 @@ def days_from_human_days(arg):

def get_holdings_and_watchlist():
tickers = set(sharesight.get_holdings_wrapper())
tickers.update(json_load('finbot_watchlist.json', persist=True))
tickers.update(json_load('finbot_watchlist.json', persist=True) or []) # absent until the first add
if 'GOOG' in tickers and 'GOOGL' in tickers:
tickers.remove("GOOGL")
tickers = sorted(set(tickers))
Expand Down
4 changes: 2 additions & 2 deletions lib/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ def alliterate():
typing.stop()
elif m_performance:
portfolio_select = None
days = config_past_days
days = config_past_days or 7 # performance needs a period; past_days = 0 means "today" elsewhere
for arg in m_performance.groups()[:2]: # groups 2 and 3, allow arbitrary order
if arg:
try:
Expand Down Expand Up @@ -330,7 +330,7 @@ def alliterate():
price_percent = float(arg.split('%')[0])
except ValueError:
if arg == m_price.group(1):
if arg == 'top':
if arg in ('top', 'bottom'): # one chart shows both ends
price_percent=0
top=10
else:
Expand Down
6 changes: 3 additions & 3 deletions lib/yahoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def fetch(tickers):
# DO NOT CACHE MORE THAN 5 mins
if not len(tickers):
print("no tickers provided to yahoo.fetch()", file=sys.stderr)
return None
return {}
tickers = sorted(set(tickers)) # de-dupe
tickers_sha256 = hashlib.sha256(str.encode("_".join(tickers))).hexdigest()
if config_cache:
Expand Down Expand Up @@ -136,7 +136,7 @@ def fetch(tickers):
data = data['quoteResponse']
if data['result'] is None or data['error'] is not None:
print(f"{tickers}†", sep=' ', end='', flush=True, file=sys.stderr)
return None
return {} # callers iterate the result
for item in data['result']:
ticker = item['symbol']
try:
Expand Down Expand Up @@ -292,7 +292,7 @@ def fetch_detail(ticker, seconds=config_cache_seconds):
# upgradeDowngradeHistory
try:
profile_title = data['quoteSummary']['result'][0]['price']['longName']
except (KeyError, IndexError, ValueError):
except (KeyError, IndexError, ValueError, TypeError): # TypeError: Yahoo answers result=null for unknown symbols
print(ticker + 'x', sep=' ', end='', flush=True, file=sys.stderr)
return {}
if profile_title is None:
Expand Down
2 changes: 1 addition & 1 deletion price.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def prepare_price_payload(service, market_data, threshold, _rows_out=None):
chart_top = chart_rows[:top]
chart_bottom = [row for row in chart_rows[-top:] if row not in chart_top]
payload = payload[:top]
heading = f'Top {top} performers {util.date_range_english(days, as_of)}'
heading = f'Top {top} performers {util.date_range_english(days, as_of) if days else on}'
elif days:
heading = f'Price movement {threshold}%, {util.date_range_english(days, as_of)}'
else:
Expand Down
4 changes: 2 additions & 2 deletions rating.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ def get_emoji(old, new):
else:
if interactive:
if specific_stock:
payload = [f"{emoji}No rating changes found for {tickers[0]}"]
payload = [f"No rating changes found for {tickers[0]}"]
else:
payload = [f"{emoji}No rating changes found"]
payload = ["No rating changes found"]
return payload, new

# MAIN #
Expand Down
Empty file added tests/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Shared fixture: points finbot at a temp cache dir and replaces every external service
(Telegram, Yahoo, Sharesight, Short Man) with canned data. Nothing here touches the network."""
import io, os, sys, tempfile, time, unittest
import pandas as pd
from unittest import mock

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.chdir(ROOT)
os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok')

from lib import webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports
import performance, shorts, price, reminder, cal, trades, milestone, rating
import bot # imported once up front: it monkey-patches with gevent, which must not happen mid-test

REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'),
(sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))}
REAL_GET_HOLDINGS = util.get_holdings_and_watchlist
REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this
TICKERS = [f'T{i}' for i in range(25)]
BOT = '@bot'


STATES = ('REGULAR', 'PRE', 'POST', 'CLOSED')


def market_entry(ticker):
i = int(ticker[1:]) if ticker[1:].isdigit() else 0 # varied data so threshold/sort/sell branches all have rows
return {'marketState': STATES[i % 4], 'regularMarketTime': time.time(), 'quoteType': 'EQUITY',
'profile_title': 'Co ' + ticker, 'profile_exchange': 'NasdaqGS', 'percent_change': (i - 12) * 1.5, 'percent_change_premarket': (i - 12) * 1.5, 'percent_change_postmarket': (i - 12) * 1.5,
'market_cap': 1e9, 'price_to_earnings_trailing': 9, 'price_to_earnings_forward': 9,
'beta': 2.0, 'short_percent': 20, 'fiftyTwoWeekHigh': 120.0, 'fiftyTwoWeekLow': 80.0,
'regularMarketPrice': 100.0, 'earnings_date': time.time() + 86400 * 3,
'ex_dividend_date': time.time() + 86400 * 3, 'recommend': 'buy' if i % 2 else 'sell', 'recommend_index': 1.2 if i % 2 else 4.2,
'recommend_analysts': 4, 'price_to_earnings_peg': 1.2, 'currency': 'USD'}


class FinbotCase(unittest.TestCase):
"""Base class: self.sent collects ('photo'|'text', ...) tuples for everything the bot tried to send."""

def patch(self, target, attr, value):
p = mock.patch.object(target, attr, value)
p.start()
self.addCleanup(p.stop)

def setUp(self):
fake = mock.Mock(status_code=200, text='{}')
fake.json.return_value = {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 5}}}
self.patch(__import__('requests').Session, 'request', mock.Mock(return_value=fake)) # no real network traffic
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.patch(util, 'config_var_dir', tmp.name)
self.patch(util, 'config_cache_dir', tmp.name)
self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'})
for m in (performance, shorts, price, cal, trades, milestone, rating, worker, reminder):
self.patch(m, 'webhooks', webhook.webhooks)
self.sent = []
self.errors = []
self.patch(webhook, 'report_error', lambda e, *a, **k: self.errors.append(webhook.error_line(e, k.get('context'))))
self.patch(webhook, 'sendPhoto', lambda chat, img, cap, svc, **k: self.sent.append(('photo', len(img.read()), cap.split('\n')[0])))
self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: self.sent.append(('text', payload)) or [])
self.patch(util, 'get_holdings_and_watchlist', lambda: TICKERS)
self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts})
self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)})
self.patch(yahoo, 'price_history', lambda t, days=None, **k: ({days: 1.5} if days else {'Max': 5, '1Y': 3}, None))
self.patch(yahoo, 'prefetch_history', lambda ts: None)
self.patch(yahoo, 'historic_high', lambda t: (100.0, 50.0))
dates = pd.date_range(end=pd.Timestamp.today().normalize(), periods=400)
self.patch(yahoo, 'price_series', lambda t: ('Co ' + t, 'NASDAQ', pd.Series(range(100, 500), index=dates, name=t, dtype=float)))
self.patch(shortman, 'fetch', lambda md: md)
holdings = [{'id': i, 'instrument': {'code': t, 'market_code': 'NASDAQ'}, 'portfolio': {'name': 'Rob'},
'capital_gain_percent': str(i * 4 - 40), 'value': '1000'} for i, t in enumerate(TICKERS)]
self.patch(sharesight, 'get_performance_wrapper', lambda d: {1: {'report': {'holdings': holdings}}})
self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1})
self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {
'holdings': holdings, 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}})
self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{
'id': 1, 'transaction_type': 'BUY', 'symbol': 'T1', 'market': 'NASDAQ', 'portfolio': name,
'transaction_date': time.strftime('%Y-%m-%d'), 'quantity': '10', 'price': '5.0', 'value': '50', 'brokerage_currency_code': 'USD', 'holding_id': 7}])

def command(self, text, chat='55'):
"""Run a chat message through the real command parser and handlers."""
self.sent.clear()
self.errors.clear()
worker.process_request('telegram', chat, '@u', text, BOT, 'U', '1')
self.assertEqual(self.errors, [], f'{text} crashed') # the worker reports crashes instead of raising
return list(self.sent)
Binary file added tests/golden/compare_lines.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/golden/hbar_mixed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/golden/price_line.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading