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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
96 changes: 96 additions & 0 deletions tests/test_trendln.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
calc_support_resistance,
get_extrema,
get_levels,
pandas_to_ohlc,
METHOD_NAIVE,
METHOD_NAIVECONSEC,
METHOD_NUMDIFF,
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
89 changes: 89 additions & 0 deletions trendln/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading