diff --git a/CHANGELOG.md b/CHANGELOG.md index ddabae8..6913024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Change Log =========== +0.1.12 +------- +- Added `pandas_to_ohlc(df, low_col=None, high_col=None, close_col=None)` helper + that converts any OHLC pandas DataFrame (yfinance, ccxt, or custom) into the + ``(low_series, high_series)`` tuple expected by trendln functions; + auto-detects standard column names case-insensitively (closes #15) + 0.1.11 ------- - Added `get_levels(calc_result, x, price, n=3)` function: given a pre-computed diff --git a/setup.py b/setup.py index 9eacdc7..4a24b65 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( name='trendln', - version="0.1.11", + version="0.1.12", description='Support and Resistance Trend lines Calculator for Financial Analysis', long_description=long_description, long_description_content_type='text/markdown', diff --git a/tests/test_trendln.py b/tests/test_trendln.py index 79f2db8..7e848c6 100644 --- a/tests/test_trendln.py +++ b/tests/test_trendln.py @@ -18,6 +18,7 @@ calc_support_resistance, get_extrema, get_levels, + pandas_to_ohlc, METHOD_NAIVE, METHOD_NAIVECONSEC, METHOD_NUMDIFF, @@ -279,6 +280,101 @@ def test_tuple_both_none_raises(self): calc_support_resistance((None, None)) +# --------------------------------------------------------------------------- +# pandas_to_ohlc +# --------------------------------------------------------------------------- + +class TestPandasToOhlc: + """Tests for trendln.pandas_to_ohlc().""" + + def _df(self, columns, values=None): + """Build a minimal DataFrame with given column names.""" + n = 10 + if values is None: + values = {c: [float(i) for i in range(n)] for c in columns} + return pd.DataFrame(values) + + # --- auto-detection: standard casing --- + + def test_standard_columns_returns_tuple(self): + df = self._df(['Low', 'High', 'Close']) + result = pandas_to_ohlc(df) + assert isinstance(result, tuple) and len(result) == 2 + assert list(result[0]) == list(df['Low']) + assert list(result[1]) == list(df['High']) + + def test_lowercase_columns_auto_detected(self): + df = self._df(['low', 'high', 'close']) + result = pandas_to_ohlc(df) + assert isinstance(result, tuple) and len(result) == 2 + + def test_mixed_case_columns_auto_detected(self): + df = self._df(['LOW', 'HIGH', 'CLOSE']) + result = pandas_to_ohlc(df) + assert isinstance(result, tuple) and len(result) == 2 + + # --- fallback to close only --- + + def test_close_only_returns_series(self): + df = self._df(['Open', 'Close']) + result = pandas_to_ohlc(df) + assert isinstance(result, pd.Series) + assert list(result) == list(df['Close']) + + def test_explicit_close_col(self): + df = self._df(['Date', 'Price'], values={'Date': list(range(10)), 'Price': [float(i) for i in range(10)]}) + result = pandas_to_ohlc(df, close_col='Price') + assert isinstance(result, pd.Series) + assert list(result) == list(df['Price']) + + # --- explicit column names --- + + def test_explicit_low_high_cols(self): + df = self._df(['lo', 'hi', 'cl'], values={'lo': [1.0]*10, 'hi': [2.0]*10, 'cl': [1.5]*10}) + result = pandas_to_ohlc(df, low_col='lo', high_col='hi') + assert isinstance(result, tuple) and len(result) == 2 + assert list(result[0]) == [1.0] * 10 + assert list(result[1]) == [2.0] * 10 + + def test_explicit_low_only_returns_series(self): + df = self._df(['lo', 'cl'], values={'lo': [1.0]*10, 'cl': [1.5]*10}) + result = pandas_to_ohlc(df, low_col='lo') + assert isinstance(result, pd.Series) + assert list(result) == [1.0] * 10 + + def test_explicit_high_only_returns_series(self): + df = self._df(['hi', 'cl'], values={'hi': [2.0]*10, 'cl': [1.5]*10}) + result = pandas_to_ohlc(df, high_col='hi') + assert isinstance(result, pd.Series) + assert list(result) == [2.0] * 10 + + # --- result is valid input to calc_support_resistance --- + + def test_output_feeds_calc_support_resistance(self): + low = [float(x) for x in [0, 1, 2, 3, 2, 1, 0, 1, 2, 3, 2, 1, 0, 1, 2, 3, 2, 1, 0, 1, 2, 3, 2, 1, 0]] + high = [v + 3.0 for v in low] + df = pd.DataFrame({'Low': low, 'High': high, 'Close': [(l + h) / 2 for l, h in zip(low, high)]}) + ohlc = pandas_to_ohlc(df) + result = calc_support_resistance(ohlc) + assert isinstance(result, tuple) and len(result) == 2 + + # --- error cases --- + + def test_non_dataframe_raises(self): + with pytest.raises(ValueError, match='DataFrame'): + pandas_to_ohlc([1, 2, 3]) + + def test_unknown_explicit_col_raises(self): + df = self._df(['Low', 'High']) + with pytest.raises(ValueError, match="'BadCol'"): + pandas_to_ohlc(df, low_col='BadCol') + + def test_no_price_columns_raises(self): + df = self._df(['Open', 'Volume'], values={'Open': [1.0]*10, 'Volume': [100]*10}) + with pytest.raises(ValueError, match='No suitable price column'): + pandas_to_ohlc(df) + + # --------------------------------------------------------------------------- # get_levels # --------------------------------------------------------------------------- diff --git a/trendln/__init__.py b/trendln/__init__.py index 911766b..84f19b2 100644 --- a/trendln/__init__.py +++ b/trendln/__init__.py @@ -402,6 +402,95 @@ def check_num_alike(h): import pandas as pd if type(h) is pd.Series and h.dtype.kind in 'biuf': return True else: return False +def pandas_to_ohlc(df, low_col=None, high_col=None, close_col=None): + """Convert a pandas OHLC DataFrame to the format expected by trendln functions. + + Auto-detects standard column names (case-insensitive: ``'low'``, + ``'high'``, ``'close'``) when not explicitly provided. Works directly + with DataFrames returned by yfinance, ccxt, and any other library that + follows OHLC naming conventions. + + Parameters + ---------- + df : pandas.DataFrame + DataFrame containing price data. + low_col : str or None + Column name for the low price. Auto-detected if ``None``. + high_col : str or None + Column name for the high price. Auto-detected if ``None``. + close_col : str or None + Column name for the close price; used as a fallback when neither + *low_col* nor *high_col* is available. Auto-detected if ``None``. + + Returns + ------- + tuple of (pandas.Series, pandas.Series) or pandas.Series + ``(low_series, high_series)`` when both columns are found — pass this + directly as ``hist`` to :func:`calc_support_resistance`, + :func:`get_extrema`, or the plot functions. Falls back to the close + (or sole available) Series when only one price column is found. + Pass ``df.index`` as the ``idx`` argument to + :func:`plot_sup_res_date` to get date-formatted x-axis labels. + + Raises + ------ + ValueError + If *df* is not a ``DataFrame`` or no suitable price column can be found. + + Examples + -------- + With **yfinance**:: + + import yfinance as yf + hist = yf.Ticker('^GSPC').history(period='1y') + ohlc = pandas_to_ohlc(hist) # (Low, High) tuple + mins, maxs = calc_support_resistance(ohlc) + fig = plot_sup_res_date(ohlc, hist.index) + + With a **ccxt** OHLCV DataFrame:: + + import pandas as pd + df = pd.DataFrame(candles, + columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume']) + ohlc = pandas_to_ohlc(df) + mins, maxs = calc_support_resistance(ohlc) + + Close-price only:: + + ohlc = pandas_to_ohlc(hist, close_col='Close') + mins, maxs = calc_support_resistance(ohlc) + """ + import pandas as pd + if not isinstance(df, pd.DataFrame): + raise ValueError('df must be a pandas DataFrame') + + col_map = {c.lower(): c for c in df.columns} + + def _resolve(explicit, default_key): + if explicit is not None: + if explicit not in df.columns: + raise ValueError(f'Column {explicit!r} not found in DataFrame') + return df[explicit] + actual = col_map.get(default_key) + return df[actual] if actual is not None else None + + low = _resolve(low_col, 'low') + high = _resolve(high_col, 'high') + close = _resolve(close_col, 'close') + + if low is not None and high is not None: + return (low, high) + if low is not None: + return low + if high is not None: + return high + if close is not None: + return close + raise ValueError( + 'No suitable price column found in DataFrame. ' + 'Specify low_col, high_col, or close_col, or ensure the DataFrame ' + 'has columns named Low, High, or Close (case-insensitive).') + def get_extrema(h, extmethod=METHOD_NUMDIFF, accuracy=2): #h must be single dimensional array-like object e.g. List, np.ndarray, pd.Series if type(h) is tuple and len(h) == 2 and (h[0] is None or check_num_alike(h[0])) and (h[1] is None or check_num_alike(h[1])) and (not h[0] is None or not h[1] is None):