Repository: pandas-dev/pandas

Issue:
Rounding error in Timestamp.floor()
#### Code Sample, a copy-pastable example if possible

```python
>>> pd.Timestamp('2117-01-01 00:00:45').floor('15s')
Timestamp('2117-01-01 00:00:30')

>>> pd.Timestamp('1823-01-01 00:00:01').floor('1s')
Timestamp('1823-01-01 00:00:00')
```
#### Problem description

For some timestamps more than a hundred years or so in the past or future, `Timestamp.floor()` rounds an already-round timestamp down to the next round value. I came up against the problem because I was assuming that `Timestamp.floor()` is idempotent.

I've looked at #14572 and #14440, these have both been closed as fixed but this bug is still present.

#### Expected Output
```python
>>> pd.Timestamp('2117-01-01 00:00:45').floor('15s')
Timestamp('2117-01-01 00:00:45')

>>> pd.Timestamp('1823-01-01 00:00:01').floor('1s')
Timestamp('1823-01-01 00:00:01')
```

#### Output of ``pd.show_versions()``
<details>
```
INSTALLED VERSIONS
------------------
commit: None
python: 3.6.4.final.0
python-bits: 64
OS: Linux
OS-release: 4.10.0-42-generic
machine: x86_64
processor: x86_64
byteorder: little
LC_ALL: None
LANG: en_GB.UTF-8
LOCALE: en_GB.UTF-8

pandas: 0.22.0
pytest: 3.3.0
pip: 9.0.1
setuptools: 36.5.0.post20170921
Cython: 0.27.3
numpy: 1.13.3
scipy: 1.0.0
pyarrow: None
xarray: 0.10.0
IPython: 6.2.1
sphinx: 1.6.3
patsy: 0.4.1
dateutil: 2.6.1
pytz: 2017.3
blosc: None
bottleneck: 1.2.1
tables: 3.4.2
numexpr: 2.6.2
feather: None
matplotlib: 2.1.0
openpyxl: 2.4.8
xlrd: 1.1.0
xlwt: 1.3.0
xlsxwriter: 1.0.2
lxml: 4.1.1
bs4: 4.6.0
html5lib: 0.999999999
sqlalchemy: 1.1.13
pymysql: None
psycopg2: None
jinja2: 2.9.6
s3fs: None
fastparquet: None
pandas_gbq: None
pandas_datareader: None
```

</details>



Discussion:
hmm, must be an error in the offset computation we are essentially doing

```
In [1]: from pandas.tseries.frequencies import to_offset

In [2]: divisor = to_offset('1s').nanos

In [4]: pd.Timestamp(np.floor(divisor * pd.Timestamp('1823-01-01 00:00:01').value / divisor))
Out[4]: Timestamp('1823-01-01 00:00:00.999999488')
```
We have some compensation for nanos freq, but I think that needs to be bigger as we are losing precision
PR welcome!
Definitely a round-off issue with large numbers. Some curious behavior of true division in Python 3. I would have thought that `int / float(int)` would be equivalent to `int / int`.  It is when numbers are not large, but in this case:
```
>>> value = pd.Timestamp('1823-01-01 00:00:01').value
>>> value
-4638902399000000000
>>> unit = to_offset('1s')
>>> unit
1000000000
>>> value / unit
-4638902399.0
>>> value / float(unit)
-4638902399.000001
```
Changing the division in the offset calculation from `int / float(int)` to `int / int` solves the problem, but raises issues for Python 2, which `__future__` division did appear to fix, even though it works in the terminal.

Resolving patch:
diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py
--- a/pandas/core/indexes/datetimelike.py
+++ b/pandas/core/indexes/datetimelike.py
@@ -36,6 +36,7 @@
 from pandas._libs import lib, iNaT, NaT
 from pandas._libs.tslibs.period import Period
 from pandas._libs.tslibs.timedeltas import delta_to_nanoseconds
+from pandas._libs.tslibs.timestamps import round_ns
 
 from pandas.core.indexes.base import Index, _index_shared_docs
 from pandas.util._decorators import Appender, cache_readonly
@@ -90,23 +91,9 @@ class TimelikeOps(object):
         """)
 
     def _round(self, freq, rounder):
-
-        from pandas.tseries.frequencies import to_offset
-        unit = to_offset(freq).nanos
         # round the local times
         values = _ensure_datetimelike_to_i8(self)
-        if unit < 1000 and unit % 1000 != 0:
-            # for nano rounding, work with the last 6 digits separately
-            # due to float precision
-            buff = 1000000
-            result = (buff * (values // buff) + unit *
-                      (rounder((values % buff) / float(unit))).astype('i8'))
-        elif unit >= 1000 and unit % 1000 != 0:
-            msg = 'Precision will be lost using frequency: {}'
-            warnings.warn(msg.format(freq))
-            result = (unit * rounder(values / float(unit)).astype('i8'))
-        else:
-            result = (unit * rounder(values / float(unit)).astype('i8'))
+        result = round_ns(values, rounder, freq)
         result = self._maybe_mask_results(result, fill_value=NaT)
 
         attribs = self._get_attributes_dict()


Test patch:
diff --git a/pandas/tests/indexes/datetimes/test_scalar_compat.py b/pandas/tests/indexes/datetimes/test_scalar_compat.py
--- a/pandas/tests/indexes/datetimes/test_scalar_compat.py
+++ b/pandas/tests/indexes/datetimes/test_scalar_compat.py
@@ -126,6 +126,27 @@ def test_round(self, tz):
             ts = '2016-10-17 12:00:00.001501031'
             DatetimeIndex([ts]).round('1010ns')
 
+    @pytest.mark.parametrize('test_input, rounder, freq, expected', [
+        (['2117-01-01 00:00:45'], 'floor', '15s', ['2117-01-01 00:00:45']),
+        (['2117-01-01 00:00:45'], 'ceil', '15s', ['2117-01-01 00:00:45']),
+        (['2117-01-01 00:00:45.000000012'], 'floor', '10ns',
+         ['2117-01-01 00:00:45.000000010']),
+        (['1823-01-01 00:00:01.000000012'], 'ceil', '10ns',
+         ['1823-01-01 00:00:01.000000020']),
+        (['1823-01-01 00:00:01'], 'floor', '1s', ['1823-01-01 00:00:01']),
+        (['1823-01-01 00:00:01'], 'ceil', '1s', ['1823-01-01 00:00:01']),
+        (('NaT', '1823-01-01 00:00:01'), 'floor', '1s',
+         ('NaT', '1823-01-01 00:00:01')),
+        (('NaT', '1823-01-01 00:00:01'), 'ceil', '1s',
+         ('NaT', '1823-01-01 00:00:01'))
+    ])
+    def test_ceil_floor_edge(self, tz, test_input, rounder, freq, expected):
+        dt = DatetimeIndex(list(test_input))
+        func = getattr(dt, rounder)
+        result = func(freq)
+        expected = DatetimeIndex(list(expected))
+        assert expected.equals(result)
+
     # ----------------------------------------------------------------
     # DatetimeIndex.normalize
 
diff --git a/pandas/tests/scalar/timestamp/test_unary_ops.py b/pandas/tests/scalar/timestamp/test_unary_ops.py
--- a/pandas/tests/scalar/timestamp/test_unary_ops.py
+++ b/pandas/tests/scalar/timestamp/test_unary_ops.py
@@ -10,7 +10,7 @@
 
 from pandas.compat import PY3
 from pandas._libs.tslibs.frequencies import _INVALID_FREQ_ERROR
-from pandas import Timestamp
+from pandas import Timestamp, NaT
 
 
 class TestTimestampUnaryOps(object):
@@ -93,6 +93,29 @@ def test_round_frequencies(self, freq, expected):
         result = stamp.round(freq=freq)
         assert result == expected
 
+    @pytest.mark.parametrize('test_input, rounder, freq, expected', [
+        ('2117-01-01 00:00:45', 'floor', '15s', '2117-01-01 00:00:45'),
+        ('2117-01-01 00:00:45', 'ceil', '15s', '2117-01-01 00:00:45'),
+        ('2117-01-01 00:00:45.000000012', 'floor', '10ns',
+         '2117-01-01 00:00:45.000000010'),
+        ('1823-01-01 00:00:01.000000012', 'ceil', '10ns',
+         '1823-01-01 00:00:01.000000020'),
+        ('1823-01-01 00:00:01', 'floor', '1s', '1823-01-01 00:00:01'),
+        ('1823-01-01 00:00:01', 'ceil', '1s', '1823-01-01 00:00:01'),
+        ('NaT', 'floor', '1s', 'NaT'),
+        ('NaT', 'ceil', '1s', 'NaT')
+    ])
+    def test_ceil_floor_edge(self, test_input, rounder, freq, expected):
+        dt = Timestamp(test_input)
+        func = getattr(dt, rounder)
+        result = func(freq)
+
+        if dt is NaT:
+            assert result is NaT
+        else:
+            expected = Timestamp(expected)
+            assert result == expected
+
     def test_ceil(self):
         dt = Timestamp('20130101 09:10:11')
         result = dt.ceil('D')