Skip to content

Commit

Permalink
Merge pull request #63 from shikbupt/EastMoneyFund
Browse files Browse the repository at this point in the history
Add eastmoneyfund(天天基金) source
  • Loading branch information
blais authored Jun 19, 2024
2 parents 3952326 + 83459bf commit 48508be
Show file tree
Hide file tree
Showing 3 changed files with 205 additions and 12 deletions.
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,19 @@ For more detailed guide for price fetching, read <https://beancount.github.io/do
## Price source info
The following price sources are available:

| Name | Module | Provides prices for | Base currency | Latest price? | Historical price? |
|--------|----------------------|------------------------------------------------------------------------------|----------------------------------------------------------|---------------|-------------------|
|Alphavantage| `beanprice.alphavantage` | [Stocks, FX, Crypto](http://alphavantage.co) | Many currencies |||
|Coinbase| `beanprice.coinbase` | [Most common (crypto)currencies](https://api.coinbase.com/v2/exchange-rates) | [Many currencies](https://api.coinbase.com/v2/currencies)|||
|Coincap | `beanprice.coincap` | [Most common (crypto)currencies](https://docs.coincap.io) | USD |||
|Coinmarketcap | `beanprice.coinmarketcap` | [Most common (crypto)currencies](https://coinmarketcap.com/api/documentation/v1/)| Many Currencies |||
|IEX | `beanprice.iex` | [Trading symbols](https://iextrading.com/trading/eligible-symbols/) | USD || 🚧 (Not yet!) |
|OANDA | `beanprice.oanda` | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) |||
|Quandl | `beanprice.quandl` | [Various datasets](https://www.quandl.com/search) | [Various datasets](https://www.quandl.com/search) |||
|Rates API| `beanprice.ratesapi`| [Many currencies](https://api.exchangerate.host/symbols) | [Many currencies](https://api.exchangerate.host/symbols) |||
|Thrift Savings Plan| `beanprice.tsp` | TSP Funds | USD |||
|Yahoo | `beanprice.yahoo` | Many currencies | Many currencies |||
| Name | Module | Provides prices for | Base currency | Latest price? | Historical price? |
|-------------------------|---------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------|---------------|-------------------|
| Alphavantage | `beanprice.alphavantage` | [Stocks, FX, Crypto](http://alphavantage.co) | Many currencies |||
| Coinbase | `beanprice.coinbase` | [Most common (crypto)currencies](https://api.coinbase.com/v2/exchange-rates) | [Many currencies](https://api.coinbase.com/v2/currencies) |||
| Coincap | `beanprice.coincap` | [Most common (crypto)currencies](https://docs.coincap.io) | USD |||
| Coinmarketcap | `beanprice.coinmarketcap` | [Most common (crypto)currencies](https://coinmarketcap.com/api/documentation/v1/) | Many Currencies |||
| IEX | `beanprice.iex` | [Trading symbols](https://iextrading.com/trading/eligible-symbols/) | USD || 🚧 (Not yet!) |
| OANDA | `beanprice.oanda` | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) | [Many currencies](https://developer.oanda.com/exchange-rates-api/v1/currencies/) |||
| Quandl | `beanprice.quandl` | [Various datasets](https://www.quandl.com/search) | [Various datasets](https://www.quandl.com/search) |||
| Rates API | `beanprice.ratesapi` | [Many currencies](https://api.exchangerate.host/symbols) | [Many currencies](https://api.exchangerate.host/symbols) |||
| Thrift Savings Plan | `beanprice.tsp` | TSP Funds | USD |||
| Yahoo | `beanprice.yahoo` | Many currencies | Many currencies |||
| EastMoneyFund(天天基金) | `beanprice.eastmoneyfund` | [Chinese Funds](http://fund.eastmoney.com/js/fundcode_search.js) | CNY |||


## Testing
Expand Down
108 changes: 108 additions & 0 deletions beanprice/sources/eastmoneyfund.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
A source fetching fund price(net value) from eastmoneyfund(天天基金)
which is a chinese securities company.
eastmoneyfund supports many kinds of fund, such as fixed income fund, ETF, etc.
this script only supports specific fund which table's header is following:
https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=377240.
fixed income fund is not supported, likes:
https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=040003
the API, as far as I know, is undocumented.
Prices are denoted in CNY.
Timezone information: the http API requests GMT+8,
the function transfers timezone to GMT+8 automatically
"""
import datetime
import re
from decimal import Decimal
import requests
from beanprice import source


# All of the easymoney funds are in CNY.
CURRENCY = 'CNY'

TIMEZONE = datetime.timezone(datetime.timedelta(hours=+8), 'Asia/Shanghai')


headers = {'content-type': 'application/json',
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:22.0)'
'Gecko/20100101 Firefox/22.0'}


class EastMoneyFundError(ValueError):
"An error from the EastMoneyFund API."


UnsupportTickerError = EastMoneyFundError(
'header not match, dont support this ticker type')


def parse_page(page):
tr_re = re.compile(r'<tr>(.*?)</tr>')
item_re = re.compile(
r'<td>(\d{4}-\d{2}-\d{2})</td><td.*?>(.*?)</td><td.*?>(.*?)</td>'
'<td.*?>(.*?)</td><td.*?>(.*?)</td><td.*?>(.*?)</td><td.*?></td>',
re.X)
header_match = re.compile(
r'<th.*?净值日期</th><th>单位净值</th><th>累计净值</th><th>日增长率</th>'
'<th>申购状态</th><th>赎回状态</th>.*?分红送配</th>')
table = tr_re.findall(page)
if not header_match.match(table[0]):
raise UnsupportTickerError
try:
table = [(datetime.datetime.fromisoformat(t[0]).
replace(hour=15, tzinfo=TIMEZONE), Decimal(t[1]))
for t in map(lambda x: item_re.match(x).groups(), table[1:])]
except AttributeError:
return None
return table


def get_price_series(ticker: str, time_begin: datetime.datetime, time_end: datetime.datetime):
base_url = 'https://fundf10.eastmoney.com/F10DataApi.aspx'
time_delta_day = (time_end-time_begin).days+1
pages = time_delta_day//30 + 1
res = []
for page in range(1, pages+1):
query = {'code': ticker, 'page': page,
'sdate': time_begin.astimezone(TIMEZONE).date(),
'edate': time_end.astimezone(TIMEZONE).date(), 'type': 'lsjz', 'per': 30}
response = requests.get(base_url, params=query, headers=headers)
if response.status_code != requests.codes.ok:
raise EastMoneyFundError(
f"Invalid response ({response.status_code}): {response.text}")

price = parse_page(response.text)
if price is None and page == 1:
raise EastMoneyFundError(
f'Invalid ticker {ticker} or '
f'search day {time_begin.date().isoformat()}~{time_end.date().isoformat()}')
if price is None:
break
res.extend(price)
return res


class Source(source.Source):

def get_latest_price(self, ticker):
end_time = datetime.datetime.now(TIMEZONE)
begin_time = end_time - datetime.timedelta(days=10)
prices = get_price_series(ticker, begin_time, end_time)
last_price = prices[0]
return source.SourcePrice(last_price[1], last_price[0], CURRENCY)

def get_historical_price(self, ticker, time):
prices = get_price_series(
ticker, time-datetime.timedelta(days=10), time)
last_price = prices[0]
return source.SourcePrice(last_price[1], last_price[0], CURRENCY)

def get_prices_series(self, ticker, time_begin, time_end):
res = [source.SourcePrice(x[1], x[0], CURRENCY)
for x in get_price_series(ticker, time_begin, time_end)]
return sorted(res, key=lambda x: x.time)
84 changes: 84 additions & 0 deletions beanprice/sources/eastmoneyfund_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import datetime
import unittest
from decimal import Decimal

from unittest import mock
from dateutil import tz

import requests

import eastmoneyfund
from beanprice import source


contents = '''
var apidata={ content:"<table class='w782 comm lsjz'><thead><tr><th class='first'>净值日期</th><th>单位净值</th><th>累计净值</th><th>日增长率</th><th>申购状态</th><th>赎回状态</th><th class='tor last'>分红送配</th></tr></thead><tbody><tr><td>2020-10-09</td><td class='tor bold'>5.1890</td><td class='tor bold'>5.1890</td><td class='tor bold red'>4.11%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-30</td><td class='tor bold'>4.9840</td><td class='tor bold'>4.9840</td><td class='tor bold red'>0.12%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-29</td><td class='tor bold'>4.9780</td><td class='tor bold'>4.9780</td><td class='tor bold red'>1.14%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-28</td><td class='tor bold'>4.9220</td><td class='tor bold'>4.9220</td><td class='tor bold red'>0.22%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-25</td><td class='tor bold'>4.9110</td><td class='tor bold'>4.9110</td><td class='tor bold red'>0.88%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-24</td><td class='tor bold'>4.8680</td><td class='tor bold'>4.8680</td><td class='tor bold grn'>-3.81%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-23</td><td class='tor bold'>5.0610</td><td class='tor bold'>5.0610</td><td class='tor bold red'>2.41%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-22</td><td class='tor bold'>4.9420</td><td class='tor bold'>4.9420</td><td class='tor bold grn'>-1.02%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-21</td><td class='tor bold'>4.9930</td><td class='tor bold'>4.9930</td><td class='tor bold grn'>-1.29%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-18</td><td class='tor bold'>5.0580</td><td class='tor bold'>5.0580</td><td class='tor bold red'>0.48%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-17</td><td class='tor bold'>5.0340</td><td class='tor bold'>5.0340</td><td class='tor bold red'>0.60%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-16</td><td class='tor bold'>5.0040</td><td class='tor bold'>5.0040</td><td class='tor bold grn'>-1.28%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-15</td><td class='tor bold'>5.0690</td><td class='tor bold'>5.0690</td><td class='tor bold red'>1.06%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-14</td><td class='tor bold'>5.0160</td><td class='tor bold'>5.0160</td><td class='tor bold red'>0.42%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-11</td><td class='tor bold'>4.9950</td><td class='tor bold'>4.9950</td><td class='tor bold red'>3.39%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr><tr><td>2020-09-10</td><td class='tor bold'>4.8310</td><td class='tor bold'>4.8310</td><td class='tor bold grn'>-0.29%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr></tbody></table>",records:16,pages:1,curpage:1};'''

unsupport_content = '''
var apidata={ content:"<table class='w782 comm lsjz'><thead><tr><th class='first'>净值日期</th><th>每万份收益</th><th>7日年化收益率(%)</th><th>申购状态</th><th>赎回状态</th><th class='tor last'>分红送配</th></tr></thead><tbody><tr><td>2020-09-10</td><td class='tor bold'>0.4230</td><td class='tor bold'>1.5730%</td><td>开放申购</td><td>开放赎回</td><td class='red unbold'></td></tr></tbody></table>",records:1,pages:1,curpage:1};'''


def response(contents, status_code=requests.codes.ok):
"""Return a context manager to patch a JSON response."""
response = mock.Mock()
response.status_code = status_code
response.text = contents
return mock.patch('requests.get', return_value=response)


class EastMoneyFundFetcher(unittest.TestCase):

def test_error_network(self):
with response(None, 404):
with self.assertRaises(ValueError) as exc:
eastmoneyfund.get_price_series(
'377240', datetime.datetime.now(), datetime.datetime.now())

def test_unsupport_page(self):
with response(unsupport_content):
with self.assertRaises(ValueError) as exc:
eastmoneyfund.get_price_series(
'377240', datetime.datetime.now(), datetime.datetime.now())
self.assertEqual(
eastmoneyfund.UnsupportTickerError, exc.exception)

def test_latest_price(self):
with response(contents):
srcprice = eastmoneyfund.Source().get_latest_price('377240')
self.assertIsInstance(srcprice, source.SourcePrice)
self.assertEqual(Decimal('5.1890'), srcprice.price)
self.assertEqual('CNY', srcprice.quote_currency)

def test_historical_price(self):
with response(contents):
time = datetime.datetime(2018, 3, 27, 0, 0, 0, tzinfo=tz.tzutc())
srcprice = eastmoneyfund.Source().get_historical_price('377240', time)
self.assertIsInstance(srcprice, source.SourcePrice)
self.assertEqual(Decimal('5.1890'), srcprice.price)
self.assertEqual('CNY', srcprice.quote_currency)
self.assertEqual(datetime.datetime(2020, 10, 9, 15, 0, 0,
tzinfo=eastmoneyfund.TIMEZONE),
srcprice.time)

def test_get_prices_series(self):
with response(contents):
time = datetime.datetime(2018, 3, 27, 0, 0, 0, tzinfo=tz.tzutc())
srcprice = eastmoneyfund.Source().get_prices_series(
'377240', time-datetime.timedelta(days=10), time)
self.assertIsInstance(srcprice, list)
self.assertIsInstance(srcprice[-1], source.SourcePrice)
self.assertEqual(Decimal('5.1890'), srcprice[-1].price)
self.assertEqual('CNY', srcprice[-1].quote_currency)
self.assertEqual(datetime.datetime(2020, 10, 9, 15, 0, 0,
tzinfo=eastmoneyfund.TIMEZONE),
srcprice[-1].time)
self.assertIsInstance(srcprice[0], source.SourcePrice)
self.assertEqual(Decimal('4.8310'), srcprice[0].price)
self.assertEqual('CNY', srcprice[0].quote_currency)
self.assertEqual(datetime.datetime(2020, 9, 10, 15, 0, 0,
tzinfo=eastmoneyfund.TIMEZONE),
srcprice[0].time)


if __name__ == '__main__':
unittest.main()

0 comments on commit 48508be

Please sign in to comment.