在很多的文章中,我们一般阐述的是递归实现筹码分布的方法,比如https://bbs.quantclass.cn/thread/31971,递归实现的逻辑形如下面的伪代码
# 伪代码
当日筹码 = 前一日筹码 * (1 - 衰减率) + 当日新增筹码 * 衰减率-
优点:计算简单,适合衰减率固定的场景
-
缺点:
- 无法体现不同历史日期差异化的衰减
- 无法处理时间非线性的衰减(如指数衰减)
- 从算法上看,因为前后依赖关系,完全无法通过并行计算来加速计算
众所周知,中国股票持仓呈现出较明显的短周期特性,因此,在很多股票软件里,比如远期筹码会呈现更快的衰减。但是对于递归方法的筹码分布而言,完全无法实现这种时间衰减特性。另外,由于递归算法的前后依赖性,我们通常只能用一个单一线程去从第一个交易日开始一直积累到观察的当日,完全没有利用多线程的方法来进行加速。
实际上,筹码分布可以简化为如下的公式:
# 伪代码
当日筹码 = Σ(历史每日筹码 * 该日到当日的衰减因子)
- 优点:
- 可自定义每个历史日期独立的衰减因子
- 支持非线性衰减模型(如指数衰减、时间加权)
- 首先每日分布是可以并行计算的,另外求和这个过程也可以并行实现,所以整体上可以用多线程进行加速
- 缺点:
- 看上去计算复杂度高,需要保存历史每日数据,计算的空间复杂度高
现代计算机体系当中,存储是比较便宜的,所以这个级别的存储复杂度并没有任何问题。在后面的章节当中,我也会更进一步谈到比如时间衰减因子的相关话题。
在这个章节,我们将会实现第二部分所讲述的这个算法,在这个部分中,我们的衰减因子先不考虑除了换手率之外的其他衰减因子,以保持跟递归计算方法的比较。
程序分为三个子程序和一个前序数据处理程序
- 每日筹码的计算
- 每日衰减因子的计算
- 所有日的筹码的积累
- 前序数据处理程序
def calc_chip_everyday(df):
# 计算每一天的筹码分布
price_step = 0.01
chip_ratio_list = []
for row in tqdm(df.itertuples(), total=len(df)):
chip_list_current = calc_current_chip_original(
row.最低价, row.最高价, row.交易均价, row.收盘价, price_step
)
chip_ratio_list.append(chip_list_current)
# 直接用新 list 赋值,不要用 df.at/index
df['筹码比例'] = chip_ratio_listdef compute_survival_ratio(df):
# 功能:计算股票筹码的存活率
# 逻辑:从数据尾部向前倒推,基于换手率计算持有者留存比例
# 公式:当前筹码存活率 = 当日的换手率 × 当日的存活率
# Tips:交易首日的换手率是 1.0,表示初始状态
# date 换手率 survival_ratio
#0 2023-01-01 1.00 0.45220
#1 2023-01-02 0.20 0.56525
#2 2023-01-03 0.15 0.66500
#3 2023-01-04 0.30 0.95000
#4 2023-01-05 0.05 1.00000
# sum(换手率 * survival_ratio) = 1.0 # 这是一个很重要的验证点
survival = [1.0] # 初始化最后一行=1
for t in df['换手率'].values[-1:0:-1]: # 只遍历最后一行到第二行
survival.append(survival[-1] * (1 - t))
df['survival_ratio'] = list(reversed(survival))这一段程序比较的复杂,我们需要做一些详细的说明。
我们这里摘录一下代码注释里写的 一个简单的例子,假设某个股票从 2023 年 1 月 1 日上市,计算 2023 年 1月 5 日的筹码分布。
# date 换手率 survival_ratio
#0 2023-01-01 1.00 0.45220
#1 2023-01-02 0.20 0.56525
#2 2023-01-03 0.15 0.66500
#3 2023-01-04 0.30 0.95000
#4 2023-01-05 0.05 1.00000我们先看到最后一行,
假如说当日的筹码分布式 Chip[20230105], 那么对于最后的筹码分布的贡献而言,他应该Chip[20230105] * 0.05 * 1;
对于前一日,他的贡献是多少呢,Chip[20230104]* (1-0.05)* 0.30。
依次类推,我们有如下的列表:
| 日期 | 换手率 | 最终筹码的实际贡献 | survival_ratio |
|---|---|---|---|
| 2023-01-05 | 0.05 | 0.05*1 | 1 |
| 2023-01-04 | 0.30 | (1-0.05)*0.30 | 1-0.05 |
| 2023-01-03 | 0.15 | (1-0.05)(1-0.30)*0.15 | (1-0.05)(1-0.30) |
| 2023-01-02 | 0.20 | (1-0.05)(1-0.30)(1-0.15)*0.20 | (1-0.05)(1-0.30)(1-0.15) |
| 2023-01-01 | 1 | (1-0.05)(1-0.30)(1-0.15)(1-0.20)*1 | (1-0.05)(1-0.30)(1-0.15)(1-0.20) |
我们注意到,实际上表的第三列最终筹码的实际贡献是我们计算所需要的,但是考虑到迭代计算,我们人为的生成一个中间变量 survival_ratio,这个 survival_ratio只由换手率来决定,是一个递归的计算关系,虽然这个部分是递归,但是由于这是一个单一因子的计算,速度非常的快。
同时,我们这里可以做一个简单的验证
Σ 每日最终筹码的实际贡献 = Σ(每日换手率 * 每日 survival_ratio) = 1如果我们仔细看这个处理细节,实际上和https://bbs.quantclass.cn/thread/31971的处理不同,我们会假设首日的换手率是 1,虽然这个听起来不太对,但是实际上这就意味着,首日的交易筹码分布就是实际首日的筹码分布,如果我们看东方财富或者同花顺,也遵循了这个假设。所以这里的首日换手率是需要强制设置为 1 的。
def aggregate_chips(df):
total = defaultdict(float)
for row in df.itertuples():
for price_weight_tuple in row.筹码比例:
price_int = int(round(price_weight_tuple[0] * 100))
total[price_int] += price_weight_tuple[1] * row.survival_ratio * row.换手率
sum_weights = sum(total.values())
chip_dict = {p/100.0: w/sum_weights for p, w in total.items()}
return sorted(chip_dict.items())在这里,我们通过价格*100并且取整的方法来进行筹码的积累,处理完了之后再除以 100,这样计算会比较快。
在https://bbs.quantclass.cn/thread/31971中,作者采用了计算合并复权的方式,我们认为这样的方式把问题复杂化了,还是应该解耦复权操作。
另外,我们通常都会跟软件做筹码分布的对比,我们发现,对于软件而言,如果选择不复权,那么一个巨大的问题就是在复权那天,哪怕实际复权价格没有什么变化,也会在软件上形成一个偏离很远的筹码分布峰。
上面两个图是 301630 在同花顺软件,除权和前复权的筹码分布情况,可以看到,除权的这个筹码分布就是强硬的进行了分开,显然是没有太大意义的。我们在比较我们计算的筹码分布的时候,一定要记得选择复权后的筹码分布图进行比较。
在https://bbs.quantclass.cn/thread/31971中,复权是在筹码分布计算过程中的,耦合在一起,很不好理解,我们将进行解耦,**在筹码分布的计算中还有一个容易出错的问题,就是交易均价的处理,交易均价由当日的交易金额除以交易量得到,但是注意到这两个都是对应除权的,如果我们完全不处理,那么我们的交易均价就是一个除权的交易均价,所以我们在复权的时候,也需要对交易均价进行复权处理。**另外,我们这次的计算完全都用的是 xbx 的日交易数据,没有采用第三方的数据。
def calculate_by_stock(code):
"""
整理数据核心函数
:param code: 股票代码
:return: 一个包含该股票所有历史数据的DataFrame
这个函数 会做以下几件事情:
1.处理一下复权的问题
2.把缺失的交易日期补齐
3.标记一下ST股票
4.标记一下可交易的日期
"""
# =读入股票数据
# 全量数据读入
df = pd.read_csv(config.stock_daily_data_path+'/stock_data' + '/%s.csv' % code, encoding='gbk', skiprows=1, parse_dates=['交易日期'], on_bad_lines='warn')
# =计算涨跌幅
df['涨跌幅'] = df['收盘价'] / df['前收盘价'] - 1
df['开盘买入涨跌幅'] = df['收盘价'] / df['开盘价'] - 1 # 为之后开盘买入做好准备
df['下日_开盘买入涨跌幅'] = df['开盘买入涨跌幅'].shift(-1)
df.sort_values(by=['交易日期'], inplace=True)
df['复权因子'] = (1 + df['涨跌幅']).cumprod()
df['后复权价'] = df['复权因子'] * (df.iloc[0]['收盘价'] / df.iloc[0]['复权因子'])
df['前复权价'] = df['复权因子'] * (df.iloc[-1]['收盘价'] / df.iloc[-1]['复权因子'])
df['昨日后复权价'] = df['后复权价'].shift(1)
df['本日涨跌幅'] = df['收盘价']/df['开盘价'] - 1
df['交易日期'] = pd.to_datetime(df['交易日期'])
df['股票代码'] = code
df['上市天数'] = range(1,len(df)+1)
df['换手率']= df['成交量']/(df['流通市值']/df['收盘价'])
df['交易均价'] = df['成交额'] / df['成交量']
#df['uid'] = df[['股票代码','交易日期']].apply(generate_id,axis =1)
df.reset_index(inplace=True, drop=True)
# =计算涨跌停价格
df = cal_if_zhangdieting_with_st(df)
#################
# =计算复权价:计算所有因子当中用到的价格,都使用复权价
df = cal_fuquan_price(df) #复权的计算
df = label_ST(df) #标记ST的股票
index_data = import_sh000001_data()[['交易日期']]
df = merge_with_index_data(df, index_data) # 将股票和上证指数合并,补全停牌的日期
df = df[df['交易日期'] > start_date]
# =股票退市时间小于指数上市时间,就会出现空值
if df.empty:
return pd.DataFrame()
df = df.reset_index(drop=False)
df = df[['交易日期','股票代码','股票名称','收盘价','开盘价','最高价','最低价','交易均价','成交量','换手率','是否交易','尾盘跌停','一字跌停','一字涨停']]
df.to_csv(config.stock_daily_data_path + '/stock_data_new/' + code + '.csv')
return 0def cal_fuquan_price(input_stock_data, fuquan_type='前复权'):
"""
计算复权价
:param input_stock_data:
:param fuquan_type:复权类型,可以是'后复权'或者'前复权'
:return:
我们倾向于使用后复权来进行相关的计算
因为前复权,会使得很早的价格成为负数,统计就会出问题
为了减少后面程序的改动量,
后复权的价格被命名为**价
原始的价格被命名为除权价格
"""
df = input_stock_data
# 计算复权收盘价
num = {'后复权': 0, '前复权': -1}
price1 = input_stock_data['收盘价'].iloc[num[fuquan_type]]
#df['复权因子'] = (1.0 + input_stock_data['涨跌幅']).cumprod()
price2 = df['复权因子'].iloc[num[fuquan_type]]
df['收盘价_' + fuquan_type] = df['复权因子'] * (price1 / price2)
# 计算复权的开盘价、最高价、最低价
df['开盘价_' + fuquan_type] = input_stock_data['开盘价'] / input_stock_data['收盘价'] * df['收盘价_' + fuquan_type]
df['最高价_' + fuquan_type] = input_stock_data['最高价'] / input_stock_data['收盘价'] * df['收盘价_' + fuquan_type]
df['最低价_' + fuquan_type] = input_stock_data['最低价'] / input_stock_data['收盘价'] * df['收盘价_' + fuquan_type]
#交易均价
df['交易均价_' + fuquan_type] = input_stock_data['交易均价'] / input_stock_data['收盘价'] * df['收盘价_' + fuquan_type]
df = df.rename(columns = {"开盘价":"开盘价除权",
"最高价":"最高价除权",
"最低价":"最低价除权",
"收盘价":"收盘价除权",
"前收盘价":"前收盘价除权",
"交易均价":"交易均价除权"})
df = df.rename(columns = {'收盘价_' + fuquan_type:'收盘价',
'开盘价_' + fuquan_type:'开盘价',
'最高价_' + fuquan_type:'最高价',
'最低价_' + fuquan_type:'最低价',
'交易均价_' + fuquan_type:'交易均价'})
return df我们计算招商轮船601872,每天采用三角分布的筹码分布,
从2006-12-01 计算到 2025-06-25
递归方法耗时37.4203秒
非递归方法耗时0.5355秒
速度提高了接近 70 倍。
可见,通过非递归方法,我们可以显著提高计算速度。
当然,我们还可以进一步的用多线程来进行加速,这里就不花更多的时间进行阐述,大家可以进行进一步的探讨。
采用了非递归的方式,衰减因子可以进行一些深入的考量。
- 致命缺陷:将市场记忆衰减视为匀速过程,忽略:
- 价格位置对记忆强度的影响(套牢盘 vs 获利盘)
- 极端行情的记忆重塑效应
- 事件冲击的非连续性衰减
| 市场现象 | 传统模型失效表现 | 行为金融学解释 |
|---|---|---|
| 深套牢持仓 | 过早衰减消失 | 损失厌恶效应(损失感知强度2-2.5倍) |
| 涨停板巨量换手 | 单日过度衰减 | 注意力聚焦效应(成交密集区记忆强化) |
| 长期横盘后突破 | 历史压力区预测失效 | 休眠记忆唤醒机制 |
| 短视性厌恶损失 | 筹码并不是均匀的损失 | 在高波动性市场,散户普遍缺乏持股耐心 |
根据东方财富网的一项股民调查数据,中国股民的平均持股天数仅为23天。
另有数据显示,A股散户的平均持股期限为39.1天。这一数据反映了散户持股时间的中等水平,但整体仍较短。
机构投资者的平均持股天数显著高于散户,通常在190.3天左右。机构投资者更倾向于长期持有,其投资策略更注重价值投资和长期配置。
比如,对于中美市场而言,由于美股主要的参与者是机构投资人,中国相对来说,散户的比例更高,所以整体而言,
「时间偏好逆转」(Time Preference Reversal):投资者对近期损失的恐惧 >> 远期收益的渴望
所以,针对这个情况,我们就可以用一些方法来进行一些模拟
将传统指数衰减改造为 加速衰减函数:
- lamba_0:通过换手率计算出来的基础衰减率
- TR:当日换手率
- k:持筹敏感系数
- α:加速因子
目前,通过一些研究,获得了一些初步的成果,我觉得还需要一些时间系统的给出一些方案。
这篇文章给出了非迭代的筹码分布计算方法,并且给出了代码的实现。
进一步,现在的方法,通过每一天的筹码贡献来进行度量,从而给出了能够更好模拟实际筹码分布的可能路径。