近半年多,基金又变得异常火爆,很多小朋友开始投资基金,但是基金的选择是个头疼的问题,网上众多up主,各自心怀鬼胎、众说纷纭。之前火爆的“坤坤”,一夜之间也跌回解放前。所以,想赚钱,还得靠自己,任何人都不会对你的选择负责!
最近很流行的一句话:“你只能赚到自己认知范围内的钱。”
那么,自己如何选择?
孙子曾经曰过:**知己知彼…… **
先看看某宝的“金选好基”:
再看看已经跌下神坛的坤坤的基金在某宝上是个什么状态:
金选!
金牌经理!
惊不惊喜?意不意外?
所以,平台推荐的你敢买吗?
毛爷爷说的好:自己动手,丰衣足食。 基金好不好,我们自己来判断。
自己来分析基金的好坏,其实步骤也很简单,类似把大象放进冰箱里:
- 打开冰箱——获取数据
- 把大象塞进冰箱——分析数据
- 关上冰箱门——买入
1. 获取数据
按照国际惯例,我们以东方财富的天天基金网作为数据源:
网上其实很多教我们如何用python爬天天基金的数据的文章,很显然这些文章帮助了很多人,但也给天天基金网造成了不少的困扰,所以他们经常修改一些参数名或者id名。
好老师应该授人予渔,而非授人予鱼,文章会有点长,我把小白可能遇到的坑以及解题思路都会尽量详细的分享出来,目的就是让小白真正掌握获取数据的能力,不管别人的网站再如何变化,我们都能找到应对的办法。
数据获取方式
通常来说,公开网站的数据获取方式有两种:
- 爬虫——最简单粗暴
- Web API——最优雅
1.1 爬虫
爬虫方式是网上文章最多的,虽然看上去简单,但很多经常被爬的网站都会设计一些反爬机制,小白在实际操作时却会遇到无数的坑。坑有很多种:
- 反DDOS
- 异步加载数据
- 用户真实性验证
我们先把坑的事情放一边,回到爬虫本身,看看如何展开接下来的工作。
想爬数据,第一步就是分析页面的代码,打开浏览器的开发者模式,选择页面中数据的部分,然后分析其HTML代码的特征:
以上图为例,所有的数据都在 <table id="dbtable">...</table> 标签中。id在一个html页面中是唯一的,因此通过代码只要提取出id=“dbtable”的对象,理论上就可以得到table中的所有数据了。
但是,众所周知,事情总不会一帆风顺,不过,我先上一段代码吧:
import requests
url = 'http://fund.eastmoney.com/data/fundranking.html#tgp;c0;r;s1nzf;pn50;ddesc;qsd20200715;qed20210715;qdii;zq;gg;gzbd;gzfs;bbzt;sfbb'
# 获取url的结果
response = requests.get(url)
# 看看我们都拿到了什么东西
print(response.content)
以下是结果的局部:
b'\xef\xbb\xbf\r\n\r\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\r\n<html xmlns="http://www.w3.org/1999/xhtml">\r\n<head>\r\n <title>\xe5\xbc\x80\xe6\x94\xbe\xe5\xbc\x8f\xe5\x9f\xba\xe9\x87\x91\xe6\x8e\x92\xe8\xa1\x8c _ \xe5\xa4\xa9\xe5\xa4\xa9\xe5\x9f\xba\xe9\x87\x91\xe7\xbd\x91</title>\r\n
是不是看不懂?不要内疚,我也看不懂,因为这东西本来就有问题, \xe5\xbc\x80\xe6\x94\xbe\xe5\xbc\x8f\xe5\x9f\这些有规律但不知所云的东西其实是中文,只不过request库默认的编码方式是ISO-8859-1,在ISO-8859-1的字符集是不包涵中文的,所以就把原始信息直接丢了出来。这是小白们遇到的第一个小坑——中文字符编码问题。如何解决也非常的简单,只要做一个decode操作,把字符集设置成utf-8,那么一切都回来了。不信你就试试看:
print(response.content.decode('utf8'))
# 以下是部分结果:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>开放式基金排行 _ 天天基金网</title>
<meta name="keywords" content="基金排行,开放式基金排行,创新基金排行,货币基金排行,涨幅排行,基金排行查询,涨幅分布,自定义基金排行,股票型基金,混合型基金,债券型基金,指数型基金,保本型基金,QDII,LOF,按基金公司筛选" />
<meta name="description" content="天天基金网每日及时更新开放式基金收益率排行。" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="mobile-agent" content="format=html5; url=https://m.1234567.com.cn/?page=jjph&tab=qb" />
<meta http-equiv="Content-Language" content="zh-CN" />
<meta http-equiv="Cache-Control" content="no-cache" />
<meta http-equiv="Expires" content="-1" />
很明显,中文正常显示了,连换行也正常了。
接下来的问题就是想办法从这堆html代码中把<table id="dbtable">...</table> 的内容过滤出来。还记得前面截图中的XPath吗?
什么是XPath?不知道的看这里。
按照最简单的做法,看看我们能得到什么,先看代码:
from lxml import etree
tree = etree.HTML(response.content.decode('utf8'))
dbtab = tree.xpath('//*[@id=\"dbtable\"]')
print(dbtab[0].text)
但是结果什么都没有。也许这是我们遇到的第二个坑!回去翻翻print(response.content.decode('utf8')),找到<table id="dbtable">发现:
<table id="dbtable">
<thead>
<tr>
<th>比较</th>
<th>序号</th>
<th col="dm" class="tworow"><a><span class="ades">基金<br />
代码</span><span class="showway"></span></a></th>
<th col="jc"><a>基金简称</a></th>
<th col="jzrq"><a>日期</a></th>
<th col="dwjz"><a>单位净值</a></th>
<th col="ljjz"><a>累计净值</a></th>
<th col="rzdf"><a>日增长率</a></th>
<th col="zzf"><a>近1周</a></th>
<th col="1yzf"><a>近1月</a></th>
<th col="3yzf"><a>近3月</a></th>
<th col="6yzf"><a>近6月</a></th>
<th col="1nzf"><a>近1年</a></th>
<th col="2nzf"><a>近2年</a></th>
<th col="3nzf"><a>近3年</a></th>
<th col="jnzf"><a>今年来</a></th>
<th col="lnzf" style="white-space: nowrap;"><a>成立来</a></th>
<th col="qjzf" id="sortclass" class="datespan">
<div style="position: relative" col="qjzf">
<a onmouseover="show_tip();">自定义</a>
<b class="cal" id="calen"></b>
</div>
</th>
<th class="yh_head">手续费</th>
<th>
<label style="text-align: center; vertical-align: middle">
<input id="onlysale" style="margin-top: 0px" type="radio" value="1" name="showsale" class="md" checked="checked" /><span class="md">可购</span></label><br />
<label style="text-align: center; vertical-align: middle;">
<input style="margin-top: 0px" type="radio" value="0" name="showsale" class="md" id="allfund" checked="" /><span class="md">全部</span>
</label>
</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
只有表头,tbody竟然是空的,果然,我们遇到了第二个坑,数据异步加载。
虽然这是个坑,但有好也有坏,通常我们有两个解题思路:
- 顺着爬虫思路往下走,我们需要一个浏览器的模拟器,拿到浏览器渲染完成的数据。
- 既然是异步,那么一定有web API,这样我们就不需要用爬虫爬去数据了,省去了后面繁琐的数据整理工作。
1.1.1 浏览器模拟器
本文不作为重点,这里只简单介绍一下。python生态的浏览器模拟器最有名的就是selinum,它可以调用本地的浏览器驱动,完整的模拟浏览器的操作,包括点击事件等,非常强大,在一些需要通过交互过程才能获取到数据的场景下非常有用。
1.2 Web API
既然我们确定了有Web API,那么,应该去哪找它呢?
首先,还是去看浏览器的开发者工具:
打开Network选项卡。如果你的选项卡中有一大堆东西,可以点击左上角第二排的第二个按钮清理一下。接下来按照下图的示意,随便点击表格上方的分类按钮,如图中所示,我点击的是混合型按钮:
在下方开发者工具的列表中多了两个请求,点开最长的那个,看到这个请求的Response中刚好是表格中的内容!看来,这应该就是我们要找的东西了。复制这个请求地址:http://fund.eastmoney.com/data/rankhandler.aspx?op=ph&dt=kf&ft=hh&rs=&gs=0&sc=1nzf&st=desc&sd=2020-07-15&ed=2021-07-15&qdii=&tabSubtype=,,,,,&pi=1&pn=50&dx=1&v=0.726345617727655 我们单独打开看看:
var rankData ={ErrCode:-999,Data:"无访问权限"}
为什么会这样?我们也不是注册用户,打开网页就能正常看到数据了,哪来的访问权限?
其实这个就是网站设计的典型反爬机制,也就是前面说的第三个坑,验证用户的真实性。我简单解释一下背后的逻辑:
一个正常人类去访问这个页面,这个url地址的真正访问者应该是http://fund.eastmoney.com/data/fundranking.html 这个页面中的JavaScript代码,所以它可以通过验证流量来源的身份来确认是否是一个正常访问请求。
这个可以伪装吗?答案是肯定的。为了保持用户和服务器端的交互连续性,用户和服务器之前存在一个叫做会话的东西,在http协议中叫做request(请求)。request中可以存储许多与用户相关的信息,有一些是协议默认的,我们还可以在程序里面自定义添加一些特殊信息,实现从浏览器向服务器传递数据。
注意:通过URL传递参数的方式是明文的,请勿传递敏感信息。这种传输方式也叫GET请求。敏感信息可以采用POST非明文方式,具体方法本文不展开,感兴趣的同学请自行搜索。
说到Request,我们不妨去浏览器的开发者工具中看看刚才的url请求中都有些什么东西:
在请求的Headers里面,我们可以找到Request Headers,我们注意看里面的Host和Referer这两个字段,它俩其实就是前面说的web API去验证请求来源的依据,我们只要把和两个东西塞到我们的Request Header中,应该就能拿到数据了。来看看代码:
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Host': 'fund.eastmoney.com',
'Accept-Encoding': 'gzip, deflate',
'Referer': 'http://fund.eastmoney.com/data/fundranking.html'
}
response = requests.get(url, headers=headers)
我们增加了一个叫做headers的字典,把刚才浏览器的Request Headers中的关键内容塞进去就行,User-Agent和Cookie也可以塞进去,一般这种API还会去校验这两个东西。
伪装的Header做好了以后,我们在requests.get方法中传入刚才做好的headers,这样就能拿到真正的数据了:
var rankData = {datas:["000209,信诚新兴产业混合,XCXXCYHH,2021-07-16,4.6530,4.6530,-1.81,1.35,35.38,68.71,53.97,153.16,301.12,277.07,60.84,365.30,2013-07-17,1,142.1564,1.50%,0.15%,1,0.15%,1,157.36",
"002190,农银新能源主题,NYXNYZT,2021-07-16,3.8259,3.8259,-3.01,0.54,25.48,53.12,36.05,140.99,352.02,288.26,37.18,282.59,2016-03-29,1,138.3444,1.50%,0.15%,1,0.15%,1,268.34",
"400015,东方新能源汽车混合,DFXNYQCHH,2021-07-16,4.1490,4.6090,-3.93,-0.55,33.22,70.17,42.06,135.85,278.83,221.85,47.79,453.21,2011-12-28,1,136.1310,1.50%,0.15%,1,0.15%,1,164.20",
"001951,金鹰改革红利混合,JYGGHLHH,2021-07-16,3.3520,3.3520,-3.12,0.06,23.37,48.85,44.05,125.57,224.18,243.79,52.36,235.20,2015-12-02,1,119.8221,1.50%,0.15%,1,0.15%,1,209.80",
……
"700003,平安策略先锋混合,PACLXFHH,2021-07-16,5.3350,5.4350,0.40,2.18,33.98,61.28,37.01,82.77,208.03,233.65,42.91,483.74,2012-05-29,1,71.7518,1.50%,0.15%,1,0.15%,1,180.64"],
allRecords:4476,
pageIndex:1,
pageNum:50,
allPages:90,
allNum:8252,
gpNum:1610,
hhNum:4476,
zqNum:1983,
zsNum:1164,
bbNum:0,
qdiiNum:183,
etfNum:0,
lofNum:330,
fofNum:169
};
看起来这是我们要的数据了,但这个数据还不能被python直接识别到,因为前面有一段 *var rankData = * 的鬼东西。这个东西是js代码,对他们网站的前端游泳,但是对我们毫无意义。仔细分析一下拿到的数据发现,我们需要的其实是datas后面的 [ …… ] 里面的东西。数据的最后面还有一段allRecords:4476,pageIndex:1,pageNum:50,allPages:90,allNum:8252,gpNum:1610,hhNum:4476,zqNum:1983,zsNum:1164,bbNum:0,qdiiNum:183,etfNum:0,lofNum:330,fofNum:169。这些是一些参数值,后面我们都用得着,所以我们干脆把 {……}都弄出来,这个结构和python中的dict结构是一样的。所以,我们只需要把前面的 “var rankData =”去掉就好了,来看代码:
# 截取第14个字符到倒数第一个字符之间的所有内容,注意,最后还有一个;号也要去掉
js_data = response.text[14:-1]
# 把得到的{···}转成dict对象。这里我们用到了第三方库execjs,没有的同学自己pip install一下
dt = execjs.eval(js_data)
到这里,我们才算真正白嫖到了网站的数据。那么有了数据,接下来就是分析了。Python以爬虫和数据分析著称,而说到数据分析,必须提到的就是无所不能的pandas库。那么接下来就来教大家如何用pandas进行数据分析。
2. 分析数据
前文我们已经拿到了网站数据并且放到了一个叫做dt的字典中。但是真正我们需要的数据在dt的一个叫做datas的字段中,这个数据是一个数组,我们只需要把它取出来丢给pandas就可以了,代码也非常的简单:
import pandas as pd
em_data = pd.DataFrame(dt['datas'])
来看看我们得到的DataFrame是什么样的
很奇怪,数据的行数没问题,但是只有一列,这是什么原因?先回去看看拿到的数据:
"000209,信诚新兴产业混合,XCXXCYHH,2021-07-16,4.6530,4.6530,-1.81,1.35,35.38,68.71,53.97,153.16,301.12,277.07,60.84,365.30,2013-07-17,1,142.1564,1.50%,0.15%,1,0.15%,1,157.36",
"002190,农银新能源主题,NYXNYZT,2021-07-16,3.8259,3.8259,-3.01,0.54,25.48,53.12,36.05,140.99,352.02,288.26,37.18,282.59,2016-03-29,1,138.3444,1.50%,0.15%,1,0.15%,1,268.34"
为了方便,我截取了前面两条数据,并且做了换行处理。我们看到,每条数据都被一对“”引号包起来了,这是一个二维数组结构,但是pandas无法直接识别出来,不过也不麻烦,我们只需要做一个小小的处理就可以——对这个字段做分隔符的拆分即可:
# DataFrame的str可以自动对每行的数据进行处理
em_data = pd.DataFrame(dt['datas'])[0].str.split(',', expand = True)
# 字段拆分完毕后,为了便于分析,我们需要给它加上字段名,以下是我不辞辛苦一个个对出来的,拿走不谢~
cols=['代码', '名称', '简称', '日期','单位净值','累计净值','日增长率','近1周','近1月','近3月','近6月','近1年','近2年','近3年','今年来','成立来', '成立日期','自定义', 'A', 'B', '手续费', 'C', 'D', 'E', 'F']
em_data.columns = cols
这样我们就得到了一个完美的DataFrame:
至于后面如何进行分析,就完全是个人主观的事情了,这个必须自己去学习金融知识和统计学知识,一点点积累,技术部分自己去看pandas的文档即可,你能知道的计算方法pandas都有现成的函数可以直接使用,非常的强大。
不过,在拿到一个数据的初期,我喜欢先对数据进行一个整体的了解,做一个overview,这里强烈安利一个基于pandas的库,可以帮助我们进行数据探索——pandas_profiling,使用也非常非常简单:
import pandas_profiling
pandas_profiling.ProfileReport(df)
如果你使用Jupyter Notebook编写代码,那么经过短暂的等待,你就会看到一个非常惊人的交互式报告:
One more thing:优化
到这里,基本技能已经介绍完了,但细心的同学会发现,里面隐藏了一个小问题,就是数据量的问题。我们从开发者工具中拿到的那个url只返回了50条数据,但是一共有几千个基金,这么多数据我们应该怎么取?如果每次只能取50条,5000条数据我们就要取100次,不光速度慢,而且很有可能被对方的安全设备识别为DDOS攻击。那么,有没有更佳优雅的解决方案呢?
还记得我们前面提过的Request参数传递的事情吗?我们来分析一下这个接口的URL地址,看看能有什么发现:
# 我把它简单处理一下,在&符号后面加上换行,得到了这个东西,:
http://fund.eastmoney.com/data/rankhandler.aspx?
op=ph&
dt=kf&
ft=hh&
rs=&
gs=0&
sc=1nzf&
st=desc&
sd=2020-07-15&
ed=2021-07-15&
qdii=&
tabSubtype=,,,,,&
pi=1&
pn=50&
dx=1&
v=0.726345617727655
简单解释一下,url中的“?”号后面是请求的参数,多个参数用&符号进行连接。
根据参数值我们可以简单得到以下猜测:
- sd:start date 开始日期
- ed:end date 结束日期
- pi:page index 所在分页的页数
- pn:page number 每页的数量
剩下的参数感兴趣的同学可以自己去研究,pi和pn应该就是我们可以利用的地方。如果pn没有上限限制,那么我们就可以一次性拿到所有的数据了,我测试了4476,也就是所有的混合基金的数量,顺利拿到了所有的数据。
既然,我们摸清楚了这个接口的一些参数,是不是可以把它封装成一个python的function呢?说干就干,以下是完整的代码:
import pandas as pd
import requests
import execjs
def get_eastMoney(start_date='2020-07-15', end_date='2020-07-15', page_index=1, page_number=50, fund_type='all', sc='6yzf'):
"""
:param start_date:
:param end_date:
:param page_index:
:param page_number:
:param fund_type: 基金类型,hh为混合,all为全部
:param sc: 排序规则,6yzf为近六个月,1nzf为近1年
:return:
"""
url = 'http://fund.eastmoney.com/data/rankhandler.aspx?op=ph&dt=kf&rs=&gs=0&st=desc&qdii=&tabSubtype=,,,,,&dx=1&v=0.5137439179039982'
cols=['代码', '名称', '简称', '日期','单位净值','累计净值','日增长率','近1周','近1月','近3月','近6月','近1年','近2年','近3年','今年来','成立来', '成立日期','自定义', 'A', 'B', '手续费', 'C', 'D', 'E', 'F']
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Host': 'fund.eastmoney.com',
'Accept-Encoding': 'gzip, deflate',
'Referer': 'http://fund.eastmoney.com/data/fundranking.html'
}
params = {
'ft': fund_type,
'sd': start_date,
'ed': end_date,
'pi': page_index,
'pn': page_number,
'sc': sc
}
response = requests.get(url, headers=headers, params=params)
js_data = response.text[14:-1]
dt = execjs.eval(js_data)
print('allRecords: ', dt['allRecords'])
em_data = pd.DataFrame(dt['datas'])[0].str.split(',', expand = True)
em_data.columns = cols
return em_data
df = get_eastMoney(fund_type='hh', page_number=500)
经过一个简单的封装,我们就有了自己的python的函数,以后我们只要通过一行代码就可以得到想要的数据。