Skip to content

Commit

Permalink
fix: Apply CultureInfo.InvariantCulture not to be affected by syste…
Browse files Browse the repository at this point in the history
…m `locale` setting (#386)
  • Loading branch information
LeeDongGeon1996 authored Aug 11, 2024
1 parent a4a39ab commit 283c83f
Show file tree
Hide file tree
Showing 12 changed files with 111 additions and 42 deletions.
11 changes: 10 additions & 1 deletion .github/workflows/test-indicators-all-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ permissions:

jobs:
test:
name: indicators
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand Down Expand Up @@ -41,6 +42,14 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Test indicators
- name: Test indicators (not locale_specific)
if: runner.os != 'macOS'
run: pytest -vr A tests -m "not locale_specific"

- if: runner.os == 'macOS'
name: Test indicators, including locale_specific(ru_RU)
run: |
sed -i '' 's/export\ LC_ALL=en_US.UTF-8/export\ LC_ALL=ru_RU.UTF-8/g' ~/.bashrc
source ~/.bashrc
locale
pytest -vr A tests
9 changes: 6 additions & 3 deletions .github/workflows/test-indicators-coverage.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Indicators
name: Test with code coverage

on: ["push"]

Expand All @@ -9,8 +9,8 @@ permissions:

jobs:
test:
name: code coverage
runs-on: ubuntu-latest
name: indicators
runs-on: macos-latest

steps:
- name: Checkout source
Expand All @@ -34,6 +34,9 @@ jobs:
- name: Test indicators
run: |
sed -i '' 's/export\ LC_ALL=en_US.UTF-8/export\ LC_ALL=ru_RU.UTF-8/g' ~/.bashrc
source ~/.bashrc
locale
coverage run -m --source=stock_indicators pytest -svr A tests
coverage xml
Expand Down
25 changes: 15 additions & 10 deletions .github/workflows/test-indicators.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Indicators
name: PR unit test

on:
pull_request:
branches: ["main"]
types: [opened, synchronize, reopened]

jobs:
test:
name: unit tests
name: indicators
runs-on: ${{ matrix.os }}

permissions:
Expand All @@ -23,8 +24,8 @@ jobs:

env:

# identifying primary configuration so only one reports coverage
IS_PRIMARY: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dotnet-version == '8.x' }}
# identifying primary configuration so only one reports to console
IS_PRIMARY: ${{ matrix.os == 'macos-latest' && matrix.python-version == '3.12' && matrix.dotnet-version == '8.x' }}

steps:
- name: Checkout source
Expand All @@ -47,10 +48,14 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Test indicators
if: env.IS_PRIMARY == 'false'
run: pytest -vr A tests
- name: Test indicators (not locale_specific)
if: runner.os != 'macOS'
run: pytest -vr A tests -m "not locale_specific"

- name: Test indicators with coverage
if: env.IS_PRIMARY == 'true'
run: pytest -vr A tests --junitxml=test-results.xml
- if: runner.os == 'macOS'
name: Test indicators, including locale_specific(ru_RU)
run: |
sed -i '' 's/export\ LC_ALL=en_US.UTF-8/export\ LC_ALL=ru_RU.UTF-8/g' ~/.bashrc
source ~/.bashrc
locale
pytest -vr A tests $( $IS_PRIMARY && echo "--junitxml=test-results.xml" )
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
markers =
locale_specific: marks locale_specific test (deselect with '-m "not locale_specific"')
2 changes: 1 addition & 1 deletion stock_indicators/_cslib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from System import DateTime as CsDateTime
from System import Decimal as CsDecimal
from System import Enum as CsEnum
from System.Globalization import CultureInfo
from System.Globalization import CultureInfo as CsCultureInfo
from System.Collections.Generic import IEnumerable as CsIEnumerable
from System.Collections.Generic import List as CsList

Expand Down
4 changes: 2 additions & 2 deletions stock_indicators/_cstypes/datetime.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime as PyDateTime

from stock_indicators._cslib import CsDateTime
from stock_indicators._cslib import CultureInfo
from stock_indicators._cslib import CsCultureInfo


class DateTime:
Expand Down Expand Up @@ -32,4 +32,4 @@ def to_pydatetime(cs_datetime):
cs_datetime : `System.DateTime` of C#.
"""
if isinstance(cs_datetime, CsDateTime):
return PyDateTime.fromisoformat(cs_datetime.ToString("s", CultureInfo.InvariantCulture))
return PyDateTime.fromisoformat(cs_datetime.ToString("s", CsCultureInfo.InvariantCulture))
5 changes: 3 additions & 2 deletions stock_indicators/_cstypes/decimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from stock_indicators._cslib import CsDecimal
from stock_indicators._cslib import CsFormatException
from stock_indicators._cslib import CsCultureInfo

class Decimal:
"""
Expand All @@ -19,7 +20,7 @@ class Decimal:
"""
def __new__(cls, decimal) -> CsDecimal:
try:
return CsDecimal.Parse(str(decimal))
return CsDecimal.Parse(str(decimal), CsCultureInfo.InvariantCulture)
except CsFormatException as e:
raise ValueError("You may be using numeric data that is incompatible with your locale environment settings.\n"
"For example, you may be using decimal points instead of commas.") from e
Expand All @@ -33,4 +34,4 @@ def to_pydecimal(cs_decimal):
cs_decimal : `System.Decimal` of C# or any `object` that can be represented as a number.
"""
if cs_decimal is not None:
return PyDecimal(str(cs_decimal))
return PyDecimal(cs_decimal.ToString(CsCultureInfo.InvariantCulture))
22 changes: 11 additions & 11 deletions stock_indicators/indicators/common/candles.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,26 @@
class _CandleProperties(_Quote):
@property
def size(self) -> Optional[Decimal]:
return to_pydecimal(self.high - self.low)
return to_pydecimal(self.High - self.Low)

@property
def body(self) -> Optional[Decimal]:
return to_pydecimal(self.open - self.close \
if (self.open > self.close) \
else self.close - self.open)
return to_pydecimal(self.Open - self.Close \
if (self.Open > self.Close) \
else self.Close - self.Open)

@property
def upper_wick(self) -> Optional[Decimal]:
return to_pydecimal(self.high - (
self.open \
if self.open > self.close \
else self.close))
return to_pydecimal(self.High - (
self.Open \
if self.Open > self.Close \
else self.Close))

@property
def lower_wick(self) -> Optional[Decimal]:
return to_pydecimal((self.close \
if self.open > self.close \
else self.open) - self.low)
return to_pydecimal((self.Close \
if self.Open > self.Close \
else self.Open) - self.Low)

@property
def body_pct(self) -> Optional[float]:
Expand Down
10 changes: 5 additions & 5 deletions stock_indicators/indicators/common/quote.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ def from_csquote(cls, cs_quote: CsQuote):
"""Constructs `Quote` instance from C# `Quote` instance."""
return cls(
date=to_pydatetime(cs_quote.Date),
open=cs_quote.Open,
high=cs_quote.High,
low=cs_quote.Low,
close=cs_quote.Close,
volume=cs_quote.Volume
open=to_pydecimal(cs_quote.Open),
high=to_pydecimal(cs_quote.High),
low=to_pydecimal(cs_quote.Low),
close=to_pydecimal(cs_quote.Close),
volume=to_pydecimal(cs_quote.Volume)
)

@classmethod
Expand Down
9 changes: 3 additions & 6 deletions stock_indicators/indicators/kama.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
from decimal import Decimal
from typing import Iterable, Optional, TypeVar

from stock_indicators._cslib import CsIndicator
from stock_indicators._cstypes import List as CsList
from stock_indicators._cstypes import Decimal as CsDecimal
from stock_indicators._cstypes import to_pydecimal
from stock_indicators.indicators.common.helpers import CondenseMixin, RemoveWarmupMixin
from stock_indicators.indicators.common.results import IndicatorResults, ResultBase
from stock_indicators.indicators.common.quote import Quote
Expand Down Expand Up @@ -57,12 +54,12 @@ def efficiency_ratio(self, value):
self._csdata.ER = value

@property
def kama(self) -> Optional[Decimal]:
return to_pydecimal(self._csdata.Kama)
def kama(self) -> Optional[float]:
return self._csdata.Kama

@kama.setter
def kama(self, value):
self._csdata.Kama = CsDecimal(value)
self._csdata.Kama = value


_T = TypeVar("_T", bound=KAMAResult)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from datetime import datetime
from numbers import Number

from stock_indicators._cslib import CsCultureInfo
from stock_indicators._cstypes import DateTime as CsDateTime
from stock_indicators._cstypes import to_pydatetime

class TestCsTypes:
class TestCsTypeConversion:
def test_datetime_conversion(self):
py_datetime = datetime.now()
converted_datetime = to_pydatetime(CsDateTime(py_datetime))
Expand All @@ -29,3 +32,11 @@ def test_timezone_aware_datetime_conversion(self):
assert py_datetime.second == converted_datetime.second
# Ignore microsecond.
# assert py_datetime.microsecond == converted_datetime.microsecond

def test_auto_conversion_from_double_to_float(self):
from System import Double as CsDouble

cs_double = CsDouble.Parse('1996.1012', CsCultureInfo.InvariantCulture)
assert isinstance(cs_double, Number)
assert isinstance(cs_double, float)
assert 1996.1012 == cs_double
40 changes: 40 additions & 0 deletions tests/common/test_locale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from decimal import Decimal as PyDecimal

import pytest

from stock_indicators._cslib import CsDecimal
from stock_indicators._cstypes import Decimal as CsDecimalConverter
from stock_indicators._cstypes.decimal import to_pydecimal

@pytest.mark.locale_specific
class TestLocale:
'''
These tests are intended for environments where a comma is used as the decimal separator,
such as when the current system locale is ru_RU.UTF-8.
'''
def test_conversion_to_Python_decimal_with_comma_decimal_separator(self):
cs_decimal = CsDecimal.Parse('1996,1012')
assert '1996,1012' == str(cs_decimal)
assert PyDecimal('1996.1012') == to_pydecimal(cs_decimal)

def test_conversion_to_CSharp_decimal_with_comma_decimal_separator(self):
# Applied CultureInfo.InvariantCulture, comma as a decimal separator should be ignored.
cs_decimal = CsDecimalConverter('1996,10.12')
assert '199610,12' == str(cs_decimal)
assert PyDecimal('199610.12') == to_pydecimal(cs_decimal)

def test_re_conversion_to_CSharp_decimal_with_comma_decimal_separator(self):
# result value will be distorted
# if CsDecimal is converted into CsDecimal again, since comma as a decimal separator would be ignored.
# Note: did not add defensive logic to avoid performance loss.
cs_decimal = CsDecimalConverter('1996,10.12')
assert '199610,12' == str(cs_decimal)

cs_decimal = CsDecimalConverter(cs_decimal)
assert '19961012' == str(cs_decimal)

def test_conversion_to_double_with_comma_decimal_separator(self):
from System import Double as CsDouble

cs_double = CsDouble.Parse('1996,1012')
assert 1996.1012 == cs_double # should be period-separated float.

0 comments on commit 283c83f

Please sign in to comment.