被动组合1号开始运行了

构建被动收入组合 一文中描述了一个构建被动投资组合的想法,用来追踪A股指数。目前,这个组合已经开始运行了。

项目地址:GitHub

💡注意:该项目不构成任何投资建议,仅用于技术交流。

1号组合

1号组合是一个被动组合,主要关注沪深300、中证500、科创50、北证50、MSCI中国A50指数。

因为还没有找到合适的券商交易API,为便于操作和计算,选取了对应的场外ETF联接基金,这样可以通过在支付宝中设置定投实现买入操作,分别是:

  • 沪深300:易方达沪深300ETF联接C(007339)
  • 中证500:易方达中证500ETF联接C(007029)
  • 科创50:易方达上证科创50联接C(011609)
  • 北证50:易方达北证50成份指数C(017516)
  • MSCI中国A50:易方达MSCI中国A50互联互通ETF联接C(014533)

交易策略

为方便计算,假设本金10万元,5个指数均等分配,每个指数2万元。再将每个指数的2万元分成100份进行定投。

买入

  1. 沪深300和MSCI中国A50每周二、四定投2次,采用涨跌幅策略。
  2. 中证500、科创50和北证50每周二、三、四定投3次,采用涨跌幅策略。

卖出

每个交易日检查基金净值:

  1. 当投入份额小于25%时,持有收益大于8%,则全部卖出。
  2. 当投入份额小于50%时,持有收益大于10%,则全部卖出。
  3. 当投入份额小于75%时,持有收益大于12%,则全部卖出。
  4. 当投入份额大于75%时,持有收益大于15%,则全部卖出。

数据源

  1. 中证指数
  2. 蛋卷基金
  3. 雪球基金
  4. 东方财富
  5. 同花顺

中证指数最近又推出了中证A50指数,中证A50指数从各行业龙头上市公司证券中,选取市值最大的50只证券作为指数样本,以反映各行业最具代表性的龙头上市公司证券的整体表现。组合中未来可能会用中证A50指数替代MSCI中国A50。

指数数据

中证指数官网本是最理想的数据源,通过浏览器访问时可以发现数据是动态加载的,并且返回数据是标准json格式:

中证指数官网

但实际使用python获取数据时,发现返回的是一个验证页面,无法直接获取数据。数据要素越来越重要,对数据的保护也越来越严格,这是一个趋势。毕竟数据接口是一项收费服务,如果被轻易绕过就不太对了。

在这个过程中想起来之前关注的一个项目:tushare,它是一个免费的金融数据接口,提供了丰富的数据接口,包括股票、基金、指数等。这个项目是一个很好的数据源,可以用来获取指数数据。但是随着数据的增加,tushare的数据接口也越来越受到限制,需要付费才能使用更多的数据。

基金数据

实际上,被动组合是一个长期策略,不需要获取高频数据,只需要获取每日的净值数据即可。这样就可以使用免费的数据接口了,比如雪球基金、东方财富、同花顺等,这些网站都提供了免费的基金净值数据。数据更新慢一点也没什么影响。最后用东财的静态页面获取基金净值数据。

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def update_fund_data(fund_code: str, date: str, max_retries: int = 5):
url = f"https://fund.eastmoney.com/{fund_code}.html"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
}

session = requests.Session()
for _ in range(max_retries):
try:
response = session.get(url, headers=headers)
response.raise_for_status()
break
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}. Retrying...")
else:
print("Max retries exceeded")
return None

soup = BeautifulSoup(response.text, "html.parser")
data_item_02 = soup.find("dl", {"class": "dataItem02"})
if data_item_02 is None:
print("Failed to find data item.")
return None

data_date = data_item_02.find("dt").text.split(" ")[-1].strip("()")
unit_value = data_item_02.find(
"span", {"class": re.compile(r"ui-font-large ui-color-(red|green) ui-num")}
).text
growth_rate = data_item_02.find_all(
"span", {"class": re.compile(r"ui-font-middle ui-color-(red|green) ui-num")}
)[0].text

if data_date != date:
print(
f"The date of the fund {fund_code} data is {data_date}, current is {date}."
)
print(f"Failed to update data for {fund_code} {url}.")
return None

new_data = {"date": date, "unit_value": unit_value, "growth_rate": growth_rate}
file_path = f"../data/{fund_code}.json"
with open(file_path, "r+", encoding="utf-8") as f:
try:
fund_data = json.load(f)
except json.JSONDecodeError:
print("Failed to decode JSON data.")
return None

for item in fund_data["market_data"]:
if item["date"] == date:
item.update(new_data)
break
else:
fund_data["market_data"].append(new_data)

f.seek(0)
json.dump(fund_data, f, indent=4)
f.truncate()

print(f"Data for {fund_code} on {date} has been updated.")
return new_data

最近几天更新出来的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
 {
"name": "\u6613\u65b9\u8fbe\u4e2d\u8bc1500ETF\u8054\u63a5C",
"code": "007029",
"market_data": [
{
"date": "2024-02-20",
"unit_value": "1.1865",
"growth_rate": "-0.10%"
},
{
"date": "2024-02-21",
"unit_value": "1.1865",
"growth_rate": "0.00%"
},
{
"date": "2024-02-22",
"unit_value": "1.1942",
"growth_rate": "0.65%"
},
{
"date": "2024-02-23",
"unit_value": "1.1991",
"growth_rate": "0.41%"
},
{
"date": "2024-02-26",
"unit_value": "1.1985",
"growth_rate": "-0.05%"
},
{
"date": "2024-02-27",
"unit_value": "1.2231",
"growth_rate": "2.05%"
}
]
}

我在仓库中设置了一个定时任务,每天更新一次数据。后面有时间再给博客增加一个页面,把这个组合数据可视化出来。