Repository: ethereum/web3.py

Issue:
Better gas price engine algorithm
Depends on #494 

### What needs to be done?

The algorithm used by https://ethgasstation.info/ is pretty solid for determining gas prices.  Lets implement something similar

### How can it be done?

Implement a new gas price engine (see #494 ) that uses the following algorithm to compute gas prices.

The algorithm will be configured with the following parameters:

- Let `N` be the number of historical blocks that should be sampled.
- Let `P` be a number between [0-1] which represents the probability for gas price estimation.  0 meaning 0% probability and 1 meaning 100% probability.
- Let `D` be the desired maximum wait time in seconds for the transaction to be mined.

We then compute the following information by fetching data from the blockchain.

- Let `A` be the *average* block time for the last `N` blocks.
- Let `M` be the set of miners from the last `N` blocks.

For each miner in `M` compute:

- Let `m.totalBlock` be the number of blocks mined by a given miner.
- Let `m.minPrice` be the minimum gas price accepted for all transactions in all blocks mined by the given miner.
- Let `g_max` be the maximum gas price for all `m.minPrice` from the set of miners `M`.
- Let `g_min` be the minimum gas price for all `m.minPrice` from the set of miners `M`.

Now we compute `B`, the maximum wait time in blocks using the formula `int(ceiling(D / A))`.

Now we can use our computed data from the miners to compute the set of probabilities that a transaction will be included at each miner's min price.  For a miner `x` let `h` be the total number of blocks mined by all miners where `m.minPrice <= x.minPrice`.  The probability is computed using the formula `(1 - (N - h)/N) ** B`.  Using this formula we can compute the set of probabilities `p0, p1, ...` for each miner.  Note that each probability `p0` corresponds to a miner `m0`.  We also assume that these probabilities are ordered such that `p0 < p1, p1 < p2, ...`.

Now compute the following values.

- Let `p_max` be the maximum probability from the set of probabilities `p0, p1, ...`.
- Let `p_min` be the minimum probability from the set of probabilities `p0, p1, ...`.

Now, to compute the gas price for a transaction to be included within `B` blocks with probability `P` as follows.

- If `P` > `p_max`, the gas price is `g_max`
- If `P` < `p_min`, the gas price is `g_min`

Otherwise, we find the location within `p0, p1, ...` such that `pn <= P <= pn+1`.  The desired gas price can then be computed as follows.

If `pn == pn+1` then the desired gas price is mn.minPrice.  Otherwise...

- Let `p_left` be `pn`
- Let `p_right` be `pn+1`
- Let `g_left` be `mn.minPrice`
- Let `g_right` be `mn+1.minPrice`

```
(P - p_right)/(p_left - p_right) * (g_left - g_right) + g_right
```

Below is an example implementation of this formula in python.

```python
import operator
import collections
import math

from web3 import Web3

from cytoolz import (
    groupby,
    sliding_window,
)

from eth_utils import (
    to_tuple,
)

#w3 = Web3(Web3.IPCProvider())
w3 = Web3(Web3.HTTPProvider('https://mainnet.infura.io'))


MinerData = collections.namedtuple('MinerData', ['miner', 'num_blocks', 'min_gas_price'])
Probability = collections.namedtuple('Probability', ['gas_price', 'prob'])

#SAMPLE_SIZE = 100
SAMPLE_SIZE = 10
ALLOWED_WAIT = 60
PROBABIITY = 95


def get_avg_block_time(w3, sample_size):
    latest = w3.eth.getBlock('latest')
    oldest = w3.eth.getBlock(latest.number - sample_size)
    return (latest.timestamp - oldest.timestamp) / sample_size


def get_raw_miner_data(w3, sample_size):
    latest = w3.eth.getBlock('latest', full_transactions=True)
    blocks = [latest] + [w3.eth.getBlock(latest.number - i, full_transactions=True) for i in range(sample_size - 1)]

    for block in blocks:
        for transaction in block.transactions:
            yield (block.miner, block.hash, transaction.gasPrice)


@to_tuple
def aggregate_miner_data(raw_data):
    data_by_miner = groupby(1, raw_data)

    for miner, miner_data in data_by_miner.items():
        _, block_hashes, gas_prices = map(set, zip(*miner_data))
        yield MinerData(miner, len(set(block_hashes)), min(gas_prices))


@to_tuple
def compute_probabilities(miner_data, wait_blocks, sample_size):
    """
    Computes the probabilities that a txn will be accepted at each of the gas
    prices accepted by the miners.
    """
    miner_data_by_price = tuple(sorted(
        miner_data,
        key=operator.attrgetter('min_gas_price'),
        reverse=True,
    ))
    for idx in range(len(miner_data_by_price)):
        min_gas_price = miner_data_by_price[idx].min_gas_price
        num_blocks_accepting_price = sum(m.num_blocks for m in miner_data_by_price[idx:])
        inv_prob_per_block = (sample_size - num_blocks_accepting_price) / sample_size
        probability_accepted = 1 - inv_prob_per_block ** wait_blocks
        yield Probability(min_gas_price, probability_accepted)


def compute_gas_price(probabilities, desired_probability):
    first = probabilities[0]
    last = probabilities[-1]

    if desired_probability >= first.prob:
        return first.gas_price
    elif desired_probability <= last.prob:
        return last.gas_price

    for left, right in sliding_window(2, probabilities):
        if desired_probability < right.prob:
            continue
        elif desired_probability > left.prob:
            raise Exception('Invariant')

        adj_prob = desired_probability - right.prob
        window_size = left.prob - right.prob
        position = adj_prob / window_size
        gas_window_size = left.gas_price - right.gas_price
        gas_price = int(math.ceil(right.gas_price + gas_window_size * position))
        return gas_price
    else:
        raise Exception('Invariant')


def get_gas_price(probability=PROBABIITY, allowed_wait=ALLOWED_WAIT, sample_size=SAMPLE_SIZE):
    avg_block_time = get_avg_block_time(w3, sample_size=sample_size)
    print('AVG BLOCK TIME:', avg_block_time)
    wait_blocks = int(math.ceil(ALLOWED_WAIT / avg_block_time))
    print('WAIT BLOCKS:', wait_blocks)

    raw_data = get_raw_miner_data(w3, sample_size=sample_size)
    miner_data = aggregate_miner_data(raw_data)

    probabilities = compute_probabilities(miner_data, wait_blocks, sample_size=sample_size)
    print('PROBABIITIES:', probabilities)

    gas_price = compute_gas_price(probabilities, PROBABIITY / 100)
    print('GAS PRICE (wei)', gas_price)
    print('GAS PRICE (gwei)', Web3.fromWei(gas_price, 'gwei'))
```


Discussion:
Hi All- you might be interested in looking as this code- its a much simpler version of the ethgasstation.info code - it  works pretty well and is easy for anyone to run https://github.com/ethgasstation/gasstation-express-oracle

Resolving patch:
diff --git a/web3/gas_strategies/time_based.py b/web3/gas_strategies/time_based.py
new file mode 100644
--- /dev/null
+++ b/web3/gas_strategies/time_based.py
@@ -0,0 +1,178 @@
+import collections
+import math
+import operator
+
+from cytoolz import (
+    curry,
+)
+
+from cytoolz import (
+    groupby,
+    sliding_window,
+)
+
+from eth_utils import (
+    to_tuple,
+)
+
+
+MinerData = collections.namedtuple('MinerData', ['miner', 'num_blocks', 'min_gas_price'])
+Probability = collections.namedtuple('Probability', ['gas_price', 'prob'])
+
+
+def _get_avg_block_time(w3, sample_size):
+    latest = w3.eth.getBlock('latest')
+
+    if latest['number'] == 0 or sample_size == 0:
+        return 0
+
+    constrained_sample_size = min(sample_size, latest['number'])
+    oldest = w3.eth.getBlock(latest['number'] - constrained_sample_size)
+    return (latest['timestamp'] - oldest['timestamp']) / constrained_sample_size
+
+
+def _get_raw_miner_data(w3, sample_size):
+    latest = w3.eth.getBlock('latest', full_transactions=True)
+
+    for transaction in latest['transactions']:
+        yield (latest['miner'], latest['hash'], transaction['gasPrice'])
+
+    block = latest
+
+    for _ in range(sample_size - 1):
+        if block['number'] == 0:
+            break
+
+        # we intentionally trace backwards using parent hashes rather than
+        # block numbers to make caching the data easier to implement.
+        block = w3.eth.getBlock(block['parentHash'], full_transactions=True)
+        for transaction in block['transactions']:
+            yield (block['miner'], block['hash'], transaction['gasPrice'])
+
+
+def _aggregate_miner_data(raw_data):
+    data_by_miner = groupby(0, raw_data)
+
+    for miner, miner_data in data_by_miner.items():
+        _, block_hashes, gas_prices = map(set, zip(*miner_data))
+        yield MinerData(miner, len(set(block_hashes)), min(gas_prices))
+
+
+@to_tuple
+def _compute_probabilities(miner_data, wait_blocks, sample_size):
+    """
+    Computes the probabilities that a txn will be accepted at each of the gas
+    prices accepted by the miners.
+    """
+    miner_data_by_price = tuple(sorted(
+        miner_data,
+        key=operator.attrgetter('min_gas_price'),
+        reverse=True,
+    ))
+    for idx in range(len(miner_data_by_price)):
+        min_gas_price = miner_data_by_price[idx].min_gas_price
+        num_blocks_accepting_price = sum(m.num_blocks for m in miner_data_by_price[idx:])
+        inv_prob_per_block = (sample_size - num_blocks_accepting_price) / sample_size
+        probability_accepted = 1 - inv_prob_per_block ** wait_blocks
+        yield Probability(min_gas_price, probability_accepted)
+
+
+def _compute_gas_price(probabilities, desired_probability):
+    """
+    Given a sorted range of ``Probability`` named-tuples returns a gas price
+    computed based on where the ``desired_probability`` would fall within the
+    range.
+
+    :param probabilities: An iterable of `Probability` named-tuples sorted in reverse order.
+    :param desired_probability: An floating point representation of the desired
+        probability. (e.g. ``85% -> 0.85``)
+    """
+    first = probabilities[0]
+    last = probabilities[-1]
+
+    if desired_probability >= first.prob:
+        return first.gas_price
+    elif desired_probability <= last.prob:
+        return last.gas_price
+
+    for left, right in sliding_window(2, probabilities):
+        if desired_probability < right.prob:
+            continue
+        elif desired_probability > left.prob:
+            # This code block should never be reachable as it would indicate
+            # that we already passed by the probability window in which our
+            # `desired_probability` is located.
+            raise Exception('Invariant')
+
+        adj_prob = desired_probability - right.prob
+        window_size = left.prob - right.prob
+        position = adj_prob / window_size
+        gas_window_size = left.gas_price - right.gas_price
+        gas_price = int(math.ceil(right.gas_price + gas_window_size * position))
+        return gas_price
+    else:
+        # The initial `if/else` clause in this function handles the case where
+        # the `desired_probability` is either above or below the min/max
+        # probability found in the `probabilities`.
+        #
+        # With these two cases handled, the only way this code block should be
+        # reachable would be if the `probabilities` were not sorted correctly.
+        # Otherwise, the `desired_probability` **must** fall between two of the
+        # values in the `probabilities``.
+        raise Exception('Invariant')
+
+
+@curry
+def construct_time_based_gas_price_strategy(max_wait_seconds,
+                                            sample_size=120,
+                                            probability=98):
+    """
+    A gas pricing strategy that uses recently mined block data to derive a gas
+    price for which a transaction is likely to be mined within X seconds with
+    probability P.
+
+    :param max_wait_seconds: The desired maxiumum number of seconds the
+        transaction should take to mine.
+    :param sample_size: The number of recent blocks to sample
+    :param probability: An integer representation of the desired probability
+        that the transaction will be mined within ``max_wait_seconds``.  0 means 0%
+        and 100 means 100%.
+    """
+    def time_based_gas_price_strategy(web3, transaction_params):
+        avg_block_time = _get_avg_block_time(web3, sample_size=sample_size)
+        wait_blocks = int(math.ceil(max_wait_seconds / avg_block_time))
+
+        raw_miner_data = _get_raw_miner_data(web3, sample_size=sample_size)
+        miner_data = _aggregate_miner_data(raw_miner_data)
+
+        probabilities = _compute_probabilities(
+            miner_data,
+            wait_blocks=wait_blocks,
+            sample_size=sample_size,
+        )
+
+        gas_price = _compute_gas_price(probabilities, probability / 100)
+        return gas_price
+    return time_based_gas_price_strategy
+
+
+# fast: mine within 1 minute
+fast_gas_price_strategy = construct_time_based_gas_price_strategy(
+    max_wait_seconds=60,
+    sample_size=120,
+)
+# medium: mine within 10 minutes
+medium_gas_price_strategy = construct_time_based_gas_price_strategy(
+    max_wait_seconds=600,
+    sample_size=120,
+)
+# slow: mine within 1 hour (60 minutes)
+slow_gas_price_strategy = construct_time_based_gas_price_strategy(
+    max_wait_seconds=60 * 60,
+    sample_size=120,
+)
+# glacial: mine within the next 24 hours.
+glacial_gas_price_strategy = construct_time_based_gas_price_strategy(
+    max_wait_seconds=24 * 60 * 60,
+    sample_size=720,
+)
diff --git a/web3/middleware/cache.py b/web3/middleware/cache.py
--- a/web3/middleware/cache.py
+++ b/web3/middleware/cache.py
@@ -1,5 +1,6 @@
 import functools
 import time
+import threading
 
 import lru
 
@@ -87,18 +88,25 @@ def construct_simple_cache_middleware(
     """
     def simple_cache_middleware(make_request, web3):
         cache = cache_class()
+        lock = threading.Lock()
 
         def middleware(method, params):
-            if method in rpc_whitelist:
-                cache_key = generate_cache_key((method, params))
-                if cache_key not in cache:
-                    response = make_request(method, params)
-                    if should_cache_fn(method, params, response):
-                        cache[cache_key] = response
-                    return response
-                return cache[cache_key]
-            else:
-                return make_request(method, params)
+            lock_acquired = lock.acquire(blocking=False)
+
+            try:
+                if lock_acquired and method in rpc_whitelist:
+                    cache_key = generate_cache_key((method, params))
+                    if cache_key not in cache:
+                        response = make_request(method, params)
+                        if should_cache_fn(method, params, response):
+                            cache[cache_key] = response
+                        return response
+                    return cache[cache_key]
+                else:
+                    return make_request(method, params)
+            finally:
+                if lock_acquired:
+                    lock.release()
         return middleware
     return simple_cache_middleware
 
@@ -179,29 +187,36 @@ def construct_time_based_cache_middleware(
     """
     def time_based_cache_middleware(make_request, web3):
         cache = cache_class()
+        lock = threading.Lock()
 
         def middleware(method, params):
-            if method in rpc_whitelist:
-                cache_key = generate_cache_key((method, params))
-                if cache_key in cache:
-                    # check that the cached response is not expired.
-                    cached_at, cached_response = cache[cache_key]
-                    cached_for = time.time() - cached_at
-
-                    if cached_for <= cache_expire_seconds:
-                        return cached_response
-                    else:
-                        del cache[cache_key]
-
-                # cache either missed or expired so make the request.
-                response = make_request(method, params)
+            lock_acquired = lock.acquire(blocking=False)
+
+            try:
+                if lock_acquired and method in rpc_whitelist:
+                    cache_key = generate_cache_key((method, params))
+                    if cache_key in cache:
+                        # check that the cached response is not expired.
+                        cached_at, cached_response = cache[cache_key]
+                        cached_for = time.time() - cached_at
+
+                        if cached_for <= cache_expire_seconds:
+                            return cached_response
+                        else:
+                            del cache[cache_key]
+
+                    # cache either missed or expired so make the request.
+                    response = make_request(method, params)
 
-                if should_cache_fn(method, params, response):
-                    cache[cache_key] = (time.time(), response)
+                    if should_cache_fn(method, params, response):
+                        cache[cache_key] = (time.time(), response)
 
-                return response
-            else:
-                return make_request(method, params)
+                    return response
+                else:
+                    return make_request(method, params)
+            finally:
+                if lock_acquired:
+                    lock.release()
         return middleware
     return time_based_cache_middleware
 
@@ -271,7 +286,7 @@ def middleware(method, params):
 def _is_latest_block_number_request(method, params):
     if method != 'eth_getBlockByNumber':
         return False
-    elif params == ['latest']:
+    elif params[:1] == ['latest']:
         return True
     return False
 
@@ -350,24 +365,33 @@ def _update_block_info_cache():
                 # latest block has not been fetched so we fetch it.
                 block_info['latest_block'] = web3.eth.getBlock('latest')
 
+        lock = threading.Lock()
+
         def middleware(method, params):
-            should_try_cache = (
-                method in rpc_whitelist and
-                not _is_latest_block_number_request(method, params)
-            )
-            if should_try_cache:
-                _update_block_info_cache()
-                latest_block_hash = block_info['latest_block']['hash']
-                cache_key = generate_cache_key((latest_block_hash, method, params))
-                if cache_key in cache:
-                    return cache[cache_key]
+            lock_acquired = lock.acquire(blocking=False)
 
-                response = make_request(method, params)
-                if should_cache_fn(method, params, response):
-                    cache[cache_key] = response
-                return response
-            else:
-                return make_request(method, params)
+            try:
+                should_try_cache = (
+                    lock_acquired and
+                    method in rpc_whitelist and
+                    not _is_latest_block_number_request(method, params)
+                )
+                if should_try_cache:
+                    _update_block_info_cache()
+                    latest_block_hash = block_info['latest_block']['hash']
+                    cache_key = generate_cache_key((latest_block_hash, method, params))
+                    if cache_key in cache:
+                        return cache[cache_key]
+
+                    response = make_request(method, params)
+                    if should_cache_fn(method, params, response):
+                        cache[cache_key] = response
+                    return response
+                else:
+                    return make_request(method, params)
+            finally:
+                if lock_acquired:
+                    lock.release()
         return middleware
     return latest_block_based_cache_middleware
 


Test patch:
diff --git a/tests/core/gas-strategies/test_time_based_gas_price_strategy.py b/tests/core/gas-strategies/test_time_based_gas_price_strategy.py
new file mode 100644
--- /dev/null
+++ b/tests/core/gas-strategies/test_time_based_gas_price_strategy.py
@@ -0,0 +1,139 @@
+import pytest
+
+from web3 import Web3
+from web3.providers.base import BaseProvider
+from web3.gas_strategies.time_based import (
+    construct_time_based_gas_price_strategy,
+)
+from web3.middleware import (
+    construct_result_generator_middleware,
+)
+
+
+def _get_block_by_something(method, params):
+    block_identifier = params[0]
+    if (
+        block_identifier == 'latest' or
+        block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000005'
+    ):
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000005',
+            'number': 5,
+            'parentHash': '0x0000000000000000000000000000000000000000000000000000000000000004',
+            'transactions': [
+                {'gasPrice': 70},
+                {'gasPrice': 60},
+            ],
+            'miner': '0xA',
+            'timestamp': 100,
+        }
+    elif block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000004':
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000004',
+            'number': 4,
+            'parentHash': '0x0000000000000000000000000000000000000000000000000000000000000003',
+            'transactions': [
+                {'gasPrice': 100},
+                {'gasPrice': 80},
+                {'gasPrice': 60},
+            ],
+            'miner': '0xB',
+            'timestamp': 80,
+        }
+    elif block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000003':
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000003',
+            'number': 3,
+            'parentHash': '0x0000000000000000000000000000000000000000000000000000000000000002',
+            'transactions': [
+                {'gasPrice': 10},
+                {'gasPrice': 50},
+                {'gasPrice': 100},
+            ],
+            'miner': '0xC',
+            'timestamp': 60,
+        }
+    elif block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000002':
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000002',
+            'number': 2,
+            'parentHash': '0x0000000000000000000000000000000000000000000000000000000000000001',
+            'transactions': [
+                {'gasPrice': 50},
+                {'gasPrice': 70},
+                {'gasPrice': 100},
+            ],
+            'miner': '0xB',
+            'timestamp': 40,
+        }
+    elif block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000001':
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000001',
+            'number': 1,
+            'parentHash': '0x0000000000000000000000000000000000000000000000000000000000000000',
+            'transactions': [
+                {'gasPrice': 30},
+                {'gasPrice': 35},
+                {'gasPrice': 65},
+            ],
+            'miner': '0xA',
+            'timestamp': 20,
+        }
+    elif (
+        block_identifier == '0x0000000000000000000000000000000000000000000000000000000000000000' or
+        block_identifier == 0
+    ):
+        return {
+            'hash': '0x0000000000000000000000000000000000000000000000000000000000000000',
+            'number': 0,
+            'parentHash': None,
+            'transactions': [
+                {'gasPrice': 30},
+                {'gasPrice': 50},
+                {'gasPrice': 60},
+            ],
+            'miner': '0xA',
+            'timestamp': 0,
+        }
+    else:
+        assert False
+
+
+@pytest.mark.parametrize(
+    'strategy_params,expected',
+    (
+        # 120 second wait times
+        (dict(max_wait_seconds=80, sample_size=5, probability=98), 35),
+        (dict(max_wait_seconds=80, sample_size=5, probability=90), 27),
+        (dict(max_wait_seconds=80, sample_size=5, probability=50), 10),
+        # 60 second wait times
+        (dict(max_wait_seconds=60, sample_size=5, probability=98), 44),
+        (dict(max_wait_seconds=60, sample_size=5, probability=90), 29),
+        (dict(max_wait_seconds=60, sample_size=5, probability=50), 11),
+        # 40 second wait times
+        (dict(max_wait_seconds=40, sample_size=5, probability=98), 48),
+        (dict(max_wait_seconds=40, sample_size=5, probability=90), 38),
+        (dict(max_wait_seconds=40, sample_size=5, probability=50), 16),
+        # 20 second wait times
+        (dict(max_wait_seconds=20, sample_size=5, probability=98), 49),
+        (dict(max_wait_seconds=20, sample_size=5, probability=90), 45),
+        (dict(max_wait_seconds=20, sample_size=5, probability=50), 25),
+    ),
+)
+def test_time_based_gas_price_strategy(strategy_params, expected):
+    fixture_middleware = construct_result_generator_middleware({
+        'eth_getBlockByHash': _get_block_by_something,
+        'eth_getBlockByNumber': _get_block_by_something,
+    })
+
+    w3 = Web3(
+        providers=[BaseProvider()],
+        middlewares=[fixture_middleware],
+    )
+
+    time_based_gas_price_strategy = construct_time_based_gas_price_strategy(
+        **strategy_params,
+    )
+    w3.eth.setGasPriceStrategy(time_based_gas_price_strategy)
+    actual = w3.eth.generateGasPrice()
+    assert actual == expected