示例#1
0
class TestTickerPlugin:
    @pytest.fixture(autouse=True)
    def setup_method_fixture(self, request, tmpdir):
        self.api_key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        self.channel = '#test'
        self.channels = [self.channel]
        self.stocks = [
            ['SPY', 'S&P 500'],
            ['DIA', 'Dow'],
            ['VEU', 'Foreign'],
            ['AGG', 'US Bond'],
        ]
        self.relay_bots = [
            {"nick": "relay.bot", "user": "******", "vhost": "relay"},
        ]

        d = tmpdir.mkdir('storage')

        get_db, self.db = get_mock_db()
        self.mock_cardinal = Mock(spec=CardinalBot)
        self.mock_cardinal.network = self.network = 'irc.darkscience.net'
        self.mock_cardinal.storage_path = str(d.dirpath())
        self.mock_cardinal.get_db.side_effect = get_db

        self.plugin = TickerPlugin(self.mock_cardinal, {
            'api_key': self.api_key,
            'channels': self.channels,
            'stocks': self.stocks,
            'relay_bots': self.relay_bots,
        })

    def test_config_defaults(self):
        plugin = TickerPlugin(self.mock_cardinal, {
            'api_key': self.api_key,
        })
        assert plugin.config['api_key'] == self.api_key
        assert plugin.config['channels'] == []
        assert plugin.config['stocks'] == []
        assert plugin.config['relay_bots'] == []

    def test_missing_api_key(self):
        with pytest.raises(KeyError):
            TickerPlugin(self.mock_cardinal, {})

    def test_missing_stocks(self):
        with pytest.raises(ValueError):
            TickerPlugin(self.mock_cardinal, {
                'api_key': self.api_key,
                'stocks': [
                    ['a', 'a'],
                    ['b', 'b'],
                    ['c', 'c'],
                    ['d', 'd'],
                    ['e', 'e'],
                    ['f', 'f'],
                ],
            })

    @defer.inlineCallbacks
    def test_send_ticker(self):
        responses = [
            make_iex_response('DIA',
                              previous_close=100,
                              price=200),
            make_iex_response('AGG',
                              previous_close=100,
                              price=150.50),
            make_iex_response('VEU',
                              previous_close=100,
                              price=105),
            make_iex_response('SPY',
                              previous_close=100,
                              price=50),
        ]

        with mock_api(responses, fake_now=get_fake_now(market_is_open=True)):
            yield self.plugin.send_ticker()

        # These should be ordered per the config
        self.mock_cardinal.sendMsg.assert_called_once_with(
            self.channel,
            'S&P 500 (\x02SPY\x02): \x0304-50.00%\x03 | '
            'Dow (\x02DIA\x02): \x0309100.00%\x03 | '
            'Foreign (\x02VEU\x02): \x03095.00%\x03 | '
            'US Bond (\x02AGG\x02): \x030950.50%\x03'
        )

    @pytest.mark.parametrize("dt,should_send_ticker,should_do_predictions", [
        (datetime.datetime(2020, 3, 21, 16, 0, 0),  # Saturday 4pm
         False,
         False,),
        (datetime.datetime(2020, 3, 22, 16, 0, 0),  # Sunday 4pm
         False,
         False,),
        (datetime.datetime(2020, 3, 23, 15, 45, 45),  # Monday 3:45pm
         True,
         False,),
        (datetime.datetime(2020, 3, 23, 16, 0, 30),  # Monday 4pm
         True,
         True,),
        (datetime.datetime(2020, 3, 23, 16, 15, 0),  # Monday 4:15pm
         False,
         False,),
        (datetime.datetime(2020, 3, 27, 9, 15, 0),  # Friday 9:15am
         False,
         False,),
        (datetime.datetime(2020, 3, 27, 9, 30, 15),  # Friday 9:30am
         True,
         True,),
        (datetime.datetime(2020, 3, 27, 9, 45, 15),  # Friday 9:45am
         True,
         False,),
    ])
    @patch.object(plugin.TickerPlugin, 'do_predictions')
    @patch.object(plugin.TickerPlugin, 'send_ticker')
    @patch.object(util, 'sleep')
    @patch.object(plugin, 'est_now')
    @pytest_twisted.inlineCallbacks
    def test_tick(self,
                  est_now,
                  sleep,
                  send_ticker,
                  do_predictions,
                  dt,
                  should_send_ticker,
                  should_do_predictions):
        est_now.return_value = dt

        yield self.plugin.tick()

        if should_send_ticker:
            send_ticker.assert_called_once_with()
        else:
            assert send_ticker.mock_calls == []

        if should_do_predictions:
            sleep.assert_called_once_with(60)
            do_predictions.assert_called_once_with()
        else:
            assert sleep.mock_calls == []
            assert do_predictions.mock_calls == []

    @pytest.mark.parametrize("market_is_open", [True, False])
    @patch.object(util, 'reactor', new_callable=Clock)
    @pytest_twisted.inlineCallbacks
    def test_do_predictions(self, mock_reactor, market_is_open):
        symbol = 'SPY'
        base = 100.0

        user1 = 'user1'
        user2 = 'user2'
        prediction1 = 105.0
        prediction2 = 96.0

        actual = 95.0

        yield self.plugin.save_prediction(
            symbol,
            user1,
            base,
            prediction1,
        )
        yield self.plugin.save_prediction(
            symbol,
            user2,
            base,
            prediction2,
        )

        assert len(self.db['predictions']) == 1
        assert len(self.db['predictions'][symbol]) == 2

        response = make_iex_response(symbol, price=actual)

        with mock_api(response, fake_now=get_fake_now(market_is_open)):
            d = self.plugin.do_predictions()
            mock_reactor.advance(15)

            yield d

        assert len(self.mock_cardinal.sendMsg.mock_calls) == 3
        self.mock_cardinal.sendMsg.assert_called_with(
            self.channel,
            '{} had the closest guess for \x02{}\x02 out of {} predictions '
            'with a prediction of {:.2f} (\x0304{:.2f}%\x03) '
            'compared to the actual {} of {:.2f} (\x0304{:.2f}%\x03).'.format(
                user2,
                symbol,
                2,
                prediction2,
                -4,
                'open' if market_is_open else 'close',
                actual,
                -5))

    @patch.object(plugin, 'est_now')
    def test_send_prediction(self, mock_now):
        prediction = 105
        actual = 110
        base = 100
        nick = "nick"
        symbol = "SPY"

        # Set the datetime to a known value so the message can be tested
        tz = pytz.timezone('America/New_York')
        mock_now.return_value = tz.localize(
            datetime.datetime(2020, 3, 20, 10, 50, 0, 0))

        prediction_ = {'when': '2020-03-20 10:50:00 EDT',
                       'prediction': prediction,
                       'base': base,
                       }
        self.plugin.send_prediction(nick, symbol, prediction_, actual)

        message = ("Prediction by nick for \x02SPY\02: 105.00 (\x03095.00%\x03). "
                   "Actual value at open: 110.00 (\x030910.00%\x03). "
                   "Prediction set at 2020-03-20 10:50:00 EDT.")
        self.mock_cardinal.sendMsg.assert_called_once_with('#test', message)

    @pytest.mark.skip(reason="Not written yet")
    def test_check(self):
        pass

    @pytest.mark.parametrize("symbol,input_msg,output_msg,market_is_open", [
        ("SPY",
         "!predict SPY +5%",
         "Prediction by nick for \x02SPY\x02 at market close: 105.00 (\x03095.00%\x03) ",
         True,
         ),
        ("SPY",
         "!predict SPY -5%",
         "Prediction by nick for \x02SPY\x02 at market close: 95.00 (\x0304-5.00%\x03) ",
         True,
         ),
        ("SPY",
         "!predict SPY -5%",
         "Prediction by nick for \x02SPY\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
         False,
         ),
        # testing a few more formats of stock symbols
        ("^RUT",
         "!predict ^RUT -5%",
         "Prediction by nick for \x02^RUT\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
         False,
         ),
        ("REE.MC",
         "!predict REE.MC -5%",
         "Prediction by nick for \x02REE.MC\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
         False,
         ),
        ("LON:HDLV",
         "!predict LON:HDLV -5%",
         "Prediction by nick for \x02LON:HDLV\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
         False,
         ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict(self,
                     symbol,
                     input_msg,
                     output_msg,
                     market_is_open):
        channel = "#finance"

        fake_now = get_fake_now(market_is_open=market_is_open)

        kwargs = {'previous_close': 100} if market_is_open else {'price': 100}
        response = make_iex_response(symbol, **kwargs)

        with mock_api(response, fake_now=fake_now):
            yield self.plugin.predict(self.mock_cardinal,
                                      user_info("nick", "user", "vhost"),
                                      channel,
                                      input_msg)

        assert symbol in self.db['predictions']
        assert len(self.db['predictions'][symbol]) == 1

        self.mock_cardinal.sendMsg.assert_called_once_with(
            channel,
            output_msg)

    @pytest.mark.parametrize("message_pairs", [
        (("!predict SPY +5%",
          "Prediction by nick for \x02SPY\x02 at market close: 105.00 (\x03095.00%\x03) ",
          ),
         ("!predict SPY -5%",
          "Prediction by nick for \x02SPY\x02 at market close: 95.00 (\x0304-5.00%\x03) "
          "(replaces old prediction of 105.00 (\x03095.00%\x03) set at {})"
          ),
         )
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict_replace(self, message_pairs):
        channel = "#finance"
        symbol = 'SPY'

        response = make_iex_response(symbol, previous_close=100)

        fake_now = get_fake_now()
        for input_msg, output_msg in message_pairs:
            with mock_api(response, fake_now):
                yield self.plugin.predict(self.mock_cardinal,
                                          user_info("nick", "user", "vhost"),
                                          channel,
                                          input_msg)

                assert symbol in self.db['predictions']
                assert len(self.db['predictions'][symbol]) == 1

                self.mock_cardinal.sendMsg.assert_called_with(
                    channel,
                    output_msg.format(fake_now.strftime('%Y-%m-%d %H:%M:%S %Z'))
                    if '{}' in output_msg else
                    output_msg)

    @pytest.mark.parametrize("input_msg,output_msg", [
        ("<nick> !predict SPY +5%",
         "Prediction by nick for \x02SPY\x02 at market close: 105.00 (\x03095.00%\x03) ",
         ),
        ("<nick> !predict SPY -5%",
         "Prediction by nick for \x02SPY\x02 at market close: 95.00 (\x0304-5.00%\x03) ",
         ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict_relay_bot(self, input_msg, output_msg):
        symbol = 'SPY'
        channel = "#finance"

        response = make_iex_response(symbol, previous_close=100)
        with mock_api(response):
            yield self.plugin.predict(self.mock_cardinal,
                                      user_info("relay.bot", "relay", "relay"),
                                      channel,
                                      input_msg)

        assert symbol in self.db['predictions']
        assert len(self.db['predictions'][symbol]) == 1

        self.mock_cardinal.sendMsg.assert_called_once_with(
            channel,
            output_msg)

    @pytest.mark.parametrize("input_msg", [
        "<whoami> !predict SPY +5%",
        "<whoami> !predict SPY -5%",
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict_not_relay_bot(self, input_msg):
        channel = "#finance"

        yield self.plugin.predict(self.mock_cardinal,
                                  user_info("nick", "user", "vhost"),
                                  channel,
                                  input_msg)

        assert len(self.db['predictions']) == 0
        assert self.mock_cardinal.sendMsg.mock_calls == []

    @pytest.mark.parametrize("user,message,value,expected", [
        (
            user_info("whoami", None, None),
            "!predict SPY 5%",
            100,
            ("whoami", "SPY", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict SPY +5%",
            100,
            ("whoami", "SPY", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict SPY -5%",
            100,
            ("whoami", "SPY", 95, 100),
        ),
        (
            user_info("not.a.relay.bot", None, None),
            "<whoami> !predict SPY -5%",
            100,
            None,
        ),
        (
            user_info("relay.bot", "relay", "relay"),
            "<whoami> !predict SPY -5%",
            100,
            ("whoami", "SPY", 95, 100),
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_parse_prediction_open(
            self,
            user,
            message,
            value,
            expected,
    ):
        symbol = 'SPY'

        response = make_iex_response(symbol, previous_close=value)
        with mock_api(response):
            result = yield self.plugin.parse_prediction(user, message)

        assert result == expected

    @pytest.mark.parametrize("user,message,value,expected", [
        (
            user_info("whoami", None, None),
            "!predict SPY 500",
            100,
            ("whoami", "SPY", 500, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict SPY $100",
            100,
            ("whoami", "SPY", 100, 100),
        ),
        (
            user_info("relay.bot", "relay", "relay"),
            "<whoami> !predict SPY 500",
            100,
            ("whoami", "SPY", 500, 100),
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_parse_prediction_open_dollar_amount(
            self,
            user,
            message,
            value,
            expected,
    ):
        symbol = 'SPY'

        response = make_iex_response(symbol, previous_close=value)
        with mock_api(response):
            result = yield self.plugin.parse_prediction(user, message)

        assert result == expected

    @pytest.mark.parametrize("user,message,value,expected", [
        (
            user_info("whoami", None, None),
            "!predict SPY 5%",
            100,
            ("whoami", "SPY", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict SPY +5%",
            100,
            ("whoami", "SPY", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict SPY -5%",
            100,
            ("whoami", "SPY", 95, 100),
        ),
        (
            user_info("not.a.relay.bot", None, None),
            "<whoami> !predict SPY -5%",
            100,
            None,
        ),
        (
            user_info("relay.bot", "relay", "relay"),
            "<whoami> !predict SPY -5%",
            100,
            ("whoami", "SPY", 95, 100),
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_parse_prediction_close(
            self,
            user,
            message,
            value,
            expected,
    ):
        symbol = 'SPY'

        response = make_iex_response(symbol, price=value)
        with mock_api(response, fake_now=get_fake_now(market_is_open=False)):
            result = yield self.plugin.parse_prediction(user, message)

        assert result == expected

    @patch.object(plugin, 'est_now')
    def test_save_prediction(self, mock_now):
        symbol = 'SPY'
        nick = 'whoami'
        base = 100
        prediction = 105

        tz = pytz.timezone('America/New_York')
        mock_now.return_value = tz.localize(datetime.datetime(
            2020,
            3,
            23,
            12,
            0,
            0,
        ))
        self.plugin.save_prediction(
            symbol,
            nick,
            base,
            prediction,
        )

        assert symbol in self.db['predictions']
        assert nick in self.db['predictions'][symbol]
        actual = self.db['predictions'][symbol][nick]
        assert actual == {
            'when': '2020-03-23 12:00:00 EDT',
            'base': base,
            'prediction': prediction,
        }

    @defer.inlineCallbacks
    def test_get_daily(self):
        symbol = 'SPY'
        price = 101.0
        previous_close = 102.0

        response = make_iex_response(symbol,
                                     price=price,
                                     previous_close=previous_close,
                                     )

        expected = {
            'symbol': symbol,
            'price': price,
            'previous close': previous_close,
            # this one is calculated by our mock response function so it
            # doesn't really test anything anymore
            'change': ((price - previous_close) / previous_close) * 100,
        }

        with mock_api(response):
            result = yield self.plugin.get_daily(symbol)
        assert result == expected

    @patch.object(plugin, 'est_now')
    def test_market_is_open(self, mock_now):
        tz = pytz.timezone('America/New_York')

        # Nothing special about this time - it's a Thursday 7:49pm
        mock_now.return_value = tz.localize(datetime.datetime(
            2020,
            3,
            19,
            19,
            49,
            55,
            0,
        ))
        assert plugin.market_is_open() is False

        # The market was open earlier though
        mock_now.return_value = tz.localize(datetime.datetime(
            2020,
            3,
            19,
            13,
            49,
            55,
            0,
        ))
        assert plugin.market_is_open() is True

        # But not before 9:30am
        mock_now.return_value = tz.localize(datetime.datetime(
            2020,
            3,
            19,
            9,
            29,
            59,
            0,
        ))
        assert plugin.market_is_open() is False

        # Or this weekend
        mock_now.return_value = tz.localize(datetime.datetime(
            2020,
            3,
            14,
            13,
            49,
            55,
            0,
        ))
        assert plugin.market_is_open() is False
示例#2
0
class TestTickerPlugin(object):
    @pytest.fixture(autouse=True)
    def setup_method_fixture(self, request, tmpdir):
        self.api_key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        self.channel = '#test'
        self.channels = [self.channel]
        self.stocks = {
            'INX': 'S&P 500',
            'DJI': 'Dow',
            'VEU': 'Foreign',
            'AGG': 'US Bond',
        }
        self.relay_bots = [
            {
                "nick": "relay.bot",
                "user": "******",
                "vhost": "relay"
            },
        ]

        d = tmpdir.mkdir('storage')

        get_db, self.db = get_mock_db()
        self.mock_cardinal = Mock(spec=CardinalBot)
        self.mock_cardinal.network = self.network = 'irc.darkscience.net'
        self.mock_cardinal.storage_path = str(d.dirpath())
        self.mock_cardinal.get_db.side_effect = get_db

        self.plugin = TickerPlugin(
            self.mock_cardinal, {
                'api_key': self.api_key,
                'channels': self.channels,
                'stocks': self.stocks,
                'relay_bots': self.relay_bots,
            })

    def test_config_defaults(self):
        plugin = TickerPlugin(self.mock_cardinal, {
            'api_key': self.api_key,
        })
        assert plugin.config['api_key'] == self.api_key
        assert plugin.config['channels'] == []
        assert plugin.config['stocks'] == {}
        assert plugin.config['relay_bots'] == []

    def test_missing_api_key(self):
        with pytest.raises(KeyError):
            TickerPlugin(self.mock_cardinal, {})

    def test_missing_stocks(self):
        with pytest.raises(ValueError):
            TickerPlugin(
                self.mock_cardinal, {
                    'api_key': self.api_key,
                    'stocks': {
                        'a': 'a',
                        'b': 'b',
                        'c': 'c',
                        'd': 'd',
                        'e': 'e',
                        'f': 'f',
                    },
                })

    @defer.inlineCallbacks
    def test_send_ticker(self):
        responses = [
            make_global_quote_response('DJI', previous_close=100, close=200),
            make_global_quote_response('AGG', previous_close=100,
                                       close=150.50),
            make_global_quote_response('VEU', previous_close=100, close=105),
            make_global_quote_response('INX', previous_close=100, close=50),
        ]

        with mock_api(responses, fake_now=get_fake_now(market_is_open=True)):
            yield self.plugin.send_ticker()

        self.mock_cardinal.sendMsg.assert_called_once_with(
            self.channel, 'Dow (\x02DJI\x02): \x0309100.00%\x03 | '
            'Foreign (\x02VEU\x02): \x03095.00%\x03 | '
            'S&P 500 (\x02INX\x02): \x0304-50.00%\x03 | '
            'US Bond (\x02AGG\x02): \x030950.50%\x03')

    @pytest.mark.parametrize(
        "dt,should_send_ticker,should_do_predictions",
        [
            (
                datetime.datetime(2020, 3, 21, 16, 0, 0),  # Saturday 4pm
                False,
                False,
            ),
            (
                datetime.datetime(2020, 3, 22, 16, 0, 0),  # Sunday 4pm
                False,
                False,
            ),
            (
                datetime.datetime(2020, 3, 23, 15, 45, 45),  # Monday 3:45pm
                True,
                False,
            ),
            (
                datetime.datetime(2020, 3, 23, 16, 0, 30),  # Monday 4pm
                True,
                True,
            ),
            (
                datetime.datetime(2020, 3, 23, 16, 15, 0),  # Monday 4:15pm
                False,
                False,
            ),
            (
                datetime.datetime(2020, 3, 27, 9, 15, 0),  # Friday 9:15am
                False,
                False,
            ),
            (
                datetime.datetime(2020, 3, 27, 9, 30, 15),  # Friday 9:30am
                True,
                True,
            ),
            (
                datetime.datetime(2020, 3, 27, 9, 45, 15),  # Friday 9:45am
                True,
                False,
            ),
        ])
    @patch.object(plugin.TickerPlugin, 'do_predictions')
    @patch.object(plugin.TickerPlugin, 'send_ticker')
    @patch.object(util, 'sleep')
    @patch.object(plugin, 'est_now')
    @pytest_twisted.inlineCallbacks
    def test_tick(self, est_now, sleep, send_ticker, do_predictions, dt,
                  should_send_ticker, should_do_predictions):
        est_now.return_value = dt

        yield self.plugin.tick()

        if should_send_ticker:
            send_ticker.assert_called_once_with()
        else:
            assert send_ticker.mock_calls == []

        if should_do_predictions:
            sleep.assert_called_once_with(60)
            do_predictions.assert_called_once_with()
        else:
            assert sleep.mock_calls == []
            assert do_predictions.mock_calls == []

    @pytest.mark.parametrize("market_is_open", [True, False])
    @patch.object(util, 'reactor', new_callable=Clock)
    @pytest_twisted.inlineCallbacks
    def test_do_predictions(self, mock_reactor, market_is_open):
        symbol = 'INX'
        base = 100.0

        user1 = 'user1'
        user2 = 'user2'
        prediction1 = 105.0
        prediction2 = 96.0

        actual = 95.0

        yield self.plugin.save_prediction(
            symbol,
            user1,
            base,
            prediction1,
        )
        yield self.plugin.save_prediction(
            symbol,
            user2,
            base,
            prediction2,
        )

        assert len(self.db['predictions']) == 1
        assert len(self.db['predictions'][symbol]) == 2

        response = make_global_quote_response(symbol, close=actual)

        with mock_api(response, fake_now=get_fake_now(market_is_open)):
            d = self.plugin.do_predictions()
            mock_reactor.advance(15)

            yield d

        assert len(self.mock_cardinal.sendMsg.mock_calls) == 3
        self.mock_cardinal.sendMsg.assert_called_with(
            self.channel,
            '{} had the closest guess for \x02{}\x02 out of {} predictions '
            'with a prediction of {} (\x0304{:.2f}%\x03) '
            'compared to the actual {} of {} (\x0304{:.2f}%\x03).'.format(
                user2, symbol, 2, prediction2, -4,
                'open' if market_is_open else 'close', actual, -5))

    @patch.object(plugin, 'est_now')
    def test_send_prediction(self, mock_now):
        prediction = 105
        actual = 110
        base = 100
        nick = "nick"
        symbol = "INX"

        # Set the datetime to a known value so the message can be tested
        tz = pytz.timezone('America/New_York')
        mock_now.return_value = tz.localize(
            datetime.datetime(2020, 3, 20, 10, 50, 0, 0))

        prediction_ = {
            'when': '2020-03-20 10:50:00 EDT',
            'prediction': prediction,
            'base': base,
        }
        self.plugin.send_prediction(nick, symbol, prediction_, actual)

        message = ("Prediction by nick for \x02INX\02: 105 (\x03095.00%\x03). "
                   "Actual value at open: 110 (\x030910.00%\x03). "
                   "Prediction set at 2020-03-20 10:50:00 EDT.")
        self.mock_cardinal.sendMsg.assert_called_once_with('#test', message)

    @pytest.mark.skip(reason="Not written yet")
    def test_check(self):
        pass

    @pytest.mark.parametrize(
        "symbol,input_msg,output_msg,market_is_open",
        [
            (
                "INX",
                "!predict INX +5%",
                "Prediction by nick for \x02INX\x02 at market close: 105.00 (\x03095.00%\x03) ",
                True,
            ),
            (
                "INX",
                "!predict INX -5%",
                "Prediction by nick for \x02INX\x02 at market close: 95.00 (\x0304-5.00%\x03) ",
                True,
            ),
            (
                "INX",
                "!predict INX -5%",
                "Prediction by nick for \x02INX\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
                False,
            ),
            # testing a few more formats of stock symbols
            (
                "^RUT",
                "!predict ^RUT -5%",
                "Prediction by nick for \x02^RUT\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
                False,
            ),
            (
                "REE.MC",
                "!predict REE.MC -5%",
                "Prediction by nick for \x02REE.MC\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
                False,
            ),
            (
                "LON:HDLV",
                "!predict LON:HDLV -5%",
                "Prediction by nick for \x02LON:HDLV\x02 at market open: 95.00 (\x0304-5.00%\x03) ",
                False,
            ),
        ])
    @pytest_twisted.inlineCallbacks
    def test_predict(self, symbol, input_msg, output_msg, market_is_open):
        channel = "#finance"

        fake_now = get_fake_now(market_is_open=market_is_open)

        kwargs = {'previous_close': 100} if market_is_open else {'close': 100}
        response = make_global_quote_response(symbol, **kwargs)

        with mock_api(response, fake_now=fake_now):
            yield self.plugin.predict(self.mock_cardinal,
                                      user_info("nick", "user", "vhost"),
                                      channel, input_msg)

        assert symbol in self.db['predictions']
        assert len(self.db['predictions'][symbol]) == 1

        self.mock_cardinal.sendMsg.assert_called_once_with(channel, output_msg)

    @pytest.mark.parametrize("message_pairs", [(
        (
            "!predict INX +5%",
            "Prediction by nick for \x02INX\x02 at market close: 105.00 (\x03095.00%\x03) ",
        ),
        ("!predict INX -5%",
         "Prediction by nick for \x02INX\x02 at market close: 95.00 (\x0304-5.00%\x03) "
         "(replaces old prediction of 105.00 (\x03095.00%\x03) set at {})"),
    )])
    @pytest_twisted.inlineCallbacks
    def test_predict_replace(self, message_pairs):
        channel = "#finance"
        symbol = 'INX'

        response = make_global_quote_response(symbol, previous_close=100)

        fake_now = get_fake_now()
        for input_msg, output_msg in message_pairs:
            with mock_api(response, fake_now):
                yield self.plugin.predict(self.mock_cardinal,
                                          user_info("nick", "user", "vhost"),
                                          channel, input_msg)

                assert symbol in self.db['predictions']
                assert len(self.db['predictions'][symbol]) == 1

                self.mock_cardinal.sendMsg.assert_called_with(
                    channel,
                    output_msg.format(
                        fake_now.strftime('%Y-%m-%d %H:%M:%S %Z'))
                    if '{}' in output_msg else output_msg)

    @pytest.mark.parametrize("input_msg,output_msg", [
        (
            "<nick> !predict INX +5%",
            "Prediction by nick for \x02INX\x02 at market close: 105.00 (\x03095.00%\x03) ",
        ),
        (
            "<nick> !predict INX -5%",
            "Prediction by nick for \x02INX\x02 at market close: 95.00 (\x0304-5.00%\x03) ",
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict_relay_bot(self, input_msg, output_msg):
        symbol = 'INX'
        channel = "#finance"

        response = make_global_quote_response(symbol, previous_close=100)
        with mock_api(response):
            yield self.plugin.predict(self.mock_cardinal,
                                      user_info("relay.bot", "relay", "relay"),
                                      channel, input_msg)

        assert symbol in self.db['predictions']
        assert len(self.db['predictions'][symbol]) == 1

        self.mock_cardinal.sendMsg.assert_called_once_with(channel, output_msg)

    @pytest.mark.parametrize("input_msg", [
        "<whoami> !predict INX +5%",
        "<whoami> !predict INX -5%",
    ])
    @pytest_twisted.inlineCallbacks
    def test_predict_not_relay_bot(self, input_msg):
        channel = "#finance"

        yield self.plugin.predict(self.mock_cardinal,
                                  user_info("nick", "user", "vhost"), channel,
                                  input_msg)

        assert len(self.db['predictions']) == 0
        assert self.mock_cardinal.sendMsg.mock_calls == []

    @pytest.mark.parametrize("user,message,value,expected", [
        (
            user_info("whoami", None, None),
            "!predict INX 5%",
            100,
            ("whoami", "INX", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict INX +5%",
            100,
            ("whoami", "INX", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict INX -5%",
            100,
            ("whoami", "INX", 95, 100),
        ),
        (
            user_info("not.a.relay.bot", None, None),
            "<whoami> !predict INX -5%",
            100,
            None,
        ),
        (
            user_info("relay.bot", "relay", "relay"),
            "<whoami> !predict INX -5%",
            100,
            ("whoami", "INX", 95, 100),
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_parse_prediction_open(
        self,
        user,
        message,
        value,
        expected,
    ):
        symbol = 'INX'

        response = make_global_quote_response(symbol, previous_close=value)
        with mock_api(response):
            result = yield self.plugin.parse_prediction(user, message)

        assert result == expected

    @pytest.mark.parametrize("user,message,value,expected", [
        (
            user_info("whoami", None, None),
            "!predict INX 5%",
            100,
            ("whoami", "INX", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict INX +5%",
            100,
            ("whoami", "INX", 105, 100),
        ),
        (
            user_info("whoami", None, None),
            "!predict INX -5%",
            100,
            ("whoami", "INX", 95, 100),
        ),
        (
            user_info("not.a.relay.bot", None, None),
            "<whoami> !predict INX -5%",
            100,
            None,
        ),
        (
            user_info("relay.bot", "relay", "relay"),
            "<whoami> !predict INX -5%",
            100,
            ("whoami", "INX", 95, 100),
        ),
    ])
    @pytest_twisted.inlineCallbacks
    def test_parse_prediction_close(
        self,
        user,
        message,
        value,
        expected,
    ):
        symbol = 'INX'

        response = make_global_quote_response(symbol, close=value)
        with mock_api(response, fake_now=get_fake_now(market_is_open=False)):
            result = yield self.plugin.parse_prediction(user, message)

        assert result == expected

    @patch.object(plugin, 'est_now')
    def test_save_prediction(self, mock_now):
        symbol = 'INX'
        nick = 'whoami'
        base = 100
        prediction = 105

        tz = pytz.timezone('America/New_York')
        mock_now.return_value = tz.localize(
            datetime.datetime(
                2020,
                3,
                23,
                12,
                0,
                0,
            ))
        self.plugin.save_prediction(
            symbol,
            nick,
            base,
            prediction,
        )

        assert symbol in self.db['predictions']
        assert nick in self.db['predictions'][symbol]
        actual = self.db['predictions'][symbol][nick]
        assert actual == {
            'when': '2020-03-23 12:00:00 EDT',
            'base': base,
            'prediction': prediction,
        }

    @defer.inlineCallbacks
    def test_get_quote(self):
        symbol = 'INX'
        response = make_global_quote_response(symbol)
        r = response["Global Quote"]

        expected = {
            'symbol':
            symbol,
            'open':
            float(r['02. open']),
            'high':
            float(r['03. high']),
            'low':
            float(r['04. low']),
            'price':
            float(r['05. price']),
            'volume':
            int(r['06. volume']),
            'latest trading day':
            datetime.datetime.today().replace(hour=0,
                                              minute=0,
                                              second=0,
                                              microsecond=0),
            'previous close':
            float(r['08. previous close']),
            'change':
            float(r['09. change']),
            'change percent':
            float(r['10. change percent'][:-1]),
        }

        with mock_api(response):
            result = yield self.plugin.get_quote(symbol)

        assert result == expected

    @defer.inlineCallbacks
    def test_get_daily(self):
        symbol = 'INX'
        last_open = 100.0
        last_close = 101.0
        previous_close = 102.0

        response = make_global_quote_response(
            symbol,
            open_=last_open,
            close=last_close,
            previous_close=previous_close,
        )

        expected = {
            'symbol':
            symbol,
            'close':
            last_close,
            'open':
            last_open,
            'previous close':
            previous_close,
            # this one is calculated by our make response function so it
            # doesn't really test anything anymore
            'change':
            float('{:.4f}'.format(get_delta(last_close, previous_close))),
        }

        with mock_api(response):
            result = yield self.plugin.get_daily(symbol)
        assert result == expected

    @defer.inlineCallbacks
    def test_get_time_series_daily(self):
        symbol = 'INX'

        response = make_time_series_daily_response(symbol)
        with mock_api(response):
            result = yield self.plugin.get_time_series_daily(symbol)

        for date in response['Time Series (Daily)']:
            assert date in result
            # verify prefix is stripped and values are floats
            for key in ('open', 'high', 'low', 'close', 'volume'):
                assert key in result[date]
                assert isinstance(result[date][key], float)

    @defer.inlineCallbacks
    def test_get_time_series_daily_bad_format(self):
        symbol = 'INX'

        response = {}
        with mock_api(response):
            with pytest.raises(KeyError):
                yield self.plugin.get_time_series_daily(symbol)

    @defer.inlineCallbacks
    def test_make_av_request(self):
        # Verify that this returns the response unmodified, and that it
        # properly calculates params
        function = 'TIME_SERIES_DAILY'
        symbol = 'INX'
        outputsize = 'compact'

        response = make_time_series_daily_response(symbol)
        with mock_api(response) as defer_mock:
            result = yield self.plugin.make_av_request(function,
                                                       params={
                                                           'symbol':
                                                           symbol,
                                                           'outputsize':
                                                           outputsize,
                                                       })

        assert result == response

        defer_mock.assert_called_once_with(plugin.requests.get,
                                           plugin.AV_API_URL,
                                           params={
                                               'apikey': self.api_key,
                                               'function': function,
                                               'symbol': symbol,
                                               'outputsize': outputsize,
                                               'datatype': 'json',
                                           })

    @defer.inlineCallbacks
    def test_make_av_request_no_params(self):
        # This one is mostly just for coverage
        function = 'TIME_SERIES_DAILY'
        symbol = 'INX'

        response = make_time_series_daily_response(symbol)
        with mock_api(response) as defer_mock:
            result = yield self.plugin.make_av_request(function)

        assert result == response

        defer_mock.assert_called_once_with(plugin.requests.get,
                                           plugin.AV_API_URL,
                                           params={
                                               'apikey': self.api_key,
                                               'function': function,
                                               'datatype': 'json',
                                           })

    @patch.object(util, 'reactor', new_callable=Clock)
    @defer.inlineCallbacks
    def test_make_av_request_retry_when_throttled(self, mock_reactor):
        # Verify that this returns the response unmodified, and that it
        # properly calculates params
        function = 'TIME_SERIES_DAILY'
        symbol = 'INX'
        outputsize = 'compact'

        response = make_time_series_daily_response(symbol)
        throttle_times = plugin.MAX_RETRIES - 1
        with mock_api(response, throttle_times=throttle_times) as defer_mock:
            d = self.plugin.make_av_request(function,
                                            params={
                                                'symbol': symbol,
                                                'outputsize': outputsize,
                                            })

            # loop through retries
            for _ in range(throttle_times):
                mock_reactor.advance(plugin.RETRY_WAIT)

            result = yield d

        assert result == response

        defer_mock.assert_has_calls([
            call(plugin.requests.get,
                 plugin.AV_API_URL,
                 params={
                     'apikey': self.api_key,
                     'function': function,
                     'symbol': symbol,
                     'outputsize': outputsize,
                     'datatype': 'json',
                 })
        ] * (throttle_times + 1))

    @patch.object(util, 'reactor', new_callable=Clock)
    @defer.inlineCallbacks
    def test_make_av_request_retry_on_exception(self, mock_reactor):
        # Verify that this returns the response unmodified, and that it
        # properly calculates params
        function = 'TIME_SERIES_DAILY'
        symbol = 'INX'
        outputsize = 'compact'

        response = make_time_series_daily_response(symbol)
        raise_times = plugin.MAX_RETRIES - 1
        with mock_api(response, raise_times=raise_times) as defer_mock:
            d = self.plugin.make_av_request(function,
                                            params={
                                                'symbol': symbol,
                                                'outputsize': outputsize,
                                            })

            # loop through retries
            for _ in range(raise_times):
                mock_reactor.advance(plugin.RETRY_WAIT)

            result = yield d

        assert result == response

        defer_mock.assert_has_calls([
            call(plugin.requests.get,
                 plugin.AV_API_URL,
                 params={
                     'apikey': self.api_key,
                     'function': function,
                     'symbol': symbol,
                     'outputsize': outputsize,
                     'datatype': 'json',
                 })
        ] * (raise_times + 1))

    @patch.object(util, 'reactor', new_callable=Clock)
    @defer.inlineCallbacks
    def test_make_av_request_give_up_after_max_retries(self, mock_reactor):
        # Verify that this returns the response unmodified, and that it
        # properly calculates params
        function = 'TIME_SERIES_DAILY'
        symbol = 'INX'
        outputsize = 'compact'

        response = make_time_series_daily_response(symbol)
        raise_times = plugin.MAX_RETRIES
        with mock_api(response, raise_times=raise_times) as defer_mock:
            d = self.plugin.make_av_request(function,
                                            params={
                                                'symbol': symbol,
                                                'outputsize': outputsize,
                                            })

            # loop through retries
            for _ in range(raise_times):
                mock_reactor.advance(plugin.RETRY_WAIT)

            with pytest.raises(Exception):
                yield d

        defer_mock.assert_has_calls([
            call(plugin.requests.get,
                 plugin.AV_API_URL,
                 params={
                     'apikey': self.api_key,
                     'function': function,
                     'symbol': symbol,
                     'outputsize': outputsize,
                     'datatype': 'json',
                 })
        ] * (raise_times))

    @patch.object(plugin, 'est_now')
    def test_market_is_open(self, mock_now):
        tz = pytz.timezone('America/New_York')

        # Nothing special about this time - it's a Thursday 7:49pm
        mock_now.return_value = tz.localize(
            datetime.datetime(
                2020,
                3,
                19,
                19,
                49,
                55,
                0,
            ))
        assert plugin.market_is_open() is False

        # The market was open earlier though
        mock_now.return_value = tz.localize(
            datetime.datetime(
                2020,
                3,
                19,
                13,
                49,
                55,
                0,
            ))
        assert plugin.market_is_open() is True

        # But not before 9:30am
        mock_now.return_value = tz.localize(
            datetime.datetime(
                2020,
                3,
                19,
                9,
                29,
                59,
                0,
            ))
        assert plugin.market_is_open() is False

        # Or this weekend
        mock_now.return_value = tz.localize(
            datetime.datetime(
                2020,
                3,
                14,
                13,
                49,
                55,
                0,
            ))
        assert plugin.market_is_open() is False