添加了基金回测功能

This commit is contained in:
Sebastian
2026-01-20 19:01:00 +08:00
parent f88ef8eab8
commit b8c66ded64
8 changed files with 1464 additions and 4 deletions

View File

@@ -2001,5 +2001,348 @@ def get_data_stats():
return jsonify(stats) return jsonify(stats)
# ==================== 基金回测功能 ====================
@app.route('/api/backtest/fixed-investment', methods=['POST'])
def backtest_fixed_investment():
"""
基金定投回测
请求参数:
{
"fund_code": "000001",
"start_date": "2020-01-01",
"end_date": "2023-12-31",
"investment_type": "monthly", // monthly, weekly, lump_sum
"amount": 1000, // 每期投资金额
"initial_amount": 0, // 初始资金(可选)
"fee_rate": 0.15, // 手续费率(百分比)
"take_profit_rate": 20, // 止盈率(百分比,可选)
"stop_loss_rate": 10 // 止损率(百分比,可选,正数)
}
"""
data = request.get_json()
try:
fund_code = data.get('fund_code')
start_date = data.get('start_date')
end_date = data.get('end_date')
investment_type = data.get('investment_type', 'monthly')
# 处理可能为空的数值输入
def safe_float(val, default):
if val is None or val == '':
return default
return float(val)
amount = safe_float(data.get('amount'), 1000)
initial_amount = safe_float(data.get('initial_amount'), 0)
fee_rate = safe_float(data.get('fee_rate'), 0.15) / 100
take_profit_rate = data.get('take_profit_rate')
if take_profit_rate is not None and take_profit_rate != '':
take_profit_rate = float(take_profit_rate) / 100
else:
take_profit_rate = None
stop_loss_rate = data.get('stop_loss_rate')
if stop_loss_rate is not None and stop_loss_rate != '':
stop_loss_rate = float(stop_loss_rate) / 100
else:
stop_loss_rate = None
if not all([fund_code, start_date, end_date]):
return jsonify({'error': 'Missing required parameters'}), 400
db = get_db()
# 获取净值数据
trend = db.query(FundTrend).filter(FundTrend.fund_code == fund_code).first()
if not trend:
# 尝试从API获取并保存数据
# 注意:这里需要在引入 update_single_fund_data 之前确保其可用
# 由于 update_single_fund_data 可能定义在其他地方或需要导入
# 这里假设它不可用或逻辑复杂,暂时只依赖已有数据
# 或者如果 update_single_fund_data 是在 fund_api 中封装的方法
try:
# 尝试使用 FundAPI 实例的 update 方法,如果存在的话
# 这里假设直接访问数据库查不到就是没有
pass
except Exception as e:
print(f"Error auto-updating fund: {e}")
if not trend:
return jsonify({'error': f'Fund data not found for code {fund_code}'}), 404
net_worth_data = _json_loads(trend.net_worth_trend_json, [])
if not net_worth_data:
return jsonify({'error': 'No net worth data available'}), 404
# 转换日期格式并排序
nav_dict = {}
for item in net_worth_data:
date_str = item.get('date')
nav = item.get('net_worth')
# 修改判断逻辑,允许 net_worth 为 0 (虽然少见) 但不能为空
if date_str and nav is not None:
try:
nav_dict[date_str] = float(nav)
except (ValueError, TypeError):
continue
# 按日期排序
sorted_dates = sorted(nav_dict.keys())
if not sorted_dates:
return jsonify({'error': 'Valid net worth data is empty'}), 404
# 辅助日期解析函数
def parse_date(date_str):
for fmt in ['%Y-%m-%d', '%Y/%m/%d', '%Y%m%d', '%Y-%m-%d %H:%M:%S']:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Unknown date format: {date_str}")
# 过滤日期范围
try:
# 只取日期部分进行比较
start_dt = parse_date(start_date).replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = parse_date(end_date).replace(hour=23, minute=59, second=59, microsecond=999999)
except ValueError as e:
return jsonify({'error': f'Invalid date format: {str(e)}'}), 400
filtered_dates = []
for d in sorted_dates:
try:
current_dt = datetime.strptime(d, '%Y-%m-%d')
if start_dt <= current_dt <= end_dt:
filtered_dates.append(d)
except ValueError:
continue
if len(filtered_dates) < 2:
return jsonify({'error': f'Insufficient data in range {start_date} to {end_date}. Found {len(filtered_dates)} records.'}), 400
# 执行回测
result = _run_backtest(
nav_dict=nav_dict,
dates=filtered_dates,
investment_type=investment_type,
amount=amount,
initial_amount=initial_amount,
fee_rate=fee_rate,
take_profit_rate=take_profit_rate,
stop_loss_rate=stop_loss_rate
)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': f'Backtest execution failed: {str(e)}'}), 500
def _run_backtest(nav_dict, dates, investment_type, amount, initial_amount, fee_rate, take_profit_rate=None, stop_loss_rate=None):
"""
执行回测计算
"""
timeline = []
total_invested = 0
total_shares = 0
# 确定投资日期
investment_dates = []
if investment_type == 'lump_sum':
# 一次性投资:只在第一天
investment_dates = [dates[0]]
elif investment_type == 'monthly':
# 每月定投:每月第一个交易日
current_month = None
for date in dates:
dt = datetime.strptime(date, '%Y-%m-%d')
month_key = (dt.year, dt.month)
if month_key != current_month:
investment_dates.append(date)
current_month = month_key
elif investment_type == 'weekly':
# 每周定投:每周第一个交易日
current_week = None
for date in dates:
dt = datetime.strptime(date, '%Y-%m-%d')
week_key = (dt.year, dt.isocalendar()[1])
if week_key != current_week:
investment_dates.append(date)
current_week = week_key
# 状态标记
sold_out = False
exit_reason = None
exit_date = None
cash = 0
# 遍历所有日期,计算持仓
for i, date in enumerate(dates):
nav = nav_dict[date]
# 如果已经清仓止盈止损,后续只计算现金价值(假设不重新买入)
if sold_out:
timeline.append({
'date': date,
'invested': round(total_invested, 2),
'shares': 0,
'nav': round(nav, 4),
'value': round(cash, 2),
'return': round(cash - total_invested, 2),
'return_rate': round((cash - total_invested) / total_invested * 100, 2) if total_invested > 0 else 0,
'is_investment_day': False,
'status': 'sold',
'exit_reason': exit_reason
})
continue
# 1. 处理初始资金 (仅第一天)
if i == 0 and initial_amount > 0:
actual_amount = initial_amount * (1 - fee_rate)
shares_bought = actual_amount / nav
total_shares += shares_bought
total_invested += initial_amount
# 2. 处理定投
is_invest_day = False
if investment_type != 'lump_sum' and date in investment_dates:
actual_amount = amount * (1 - fee_rate)
shares_bought = actual_amount / nav
total_shares += shares_bought
total_invested += amount
is_invest_day = True
elif investment_type == 'lump_sum' and i == 0 and amount > 0:
# 如果是 lump_sum 且 amount > 0视为第一天投入
# 叠加 initial_amount
actual_amount = amount * (1 - fee_rate)
shares_bought = actual_amount / nav
total_shares += shares_bought
total_invested += amount
is_invest_day = True
# 计算当前市值
current_value = total_shares * nav
total_return = current_value - total_invested
return_rate = (total_return / total_invested * 100) if total_invested > 0 else 0
# 3. 检查止盈止损
triggered = False
if total_invested > 0:
if take_profit_rate and return_rate >= (take_profit_rate * 100):
sold_out = True
exit_reason = 'take_profit'
triggered = True
elif stop_loss_rate and return_rate <= -(stop_loss_rate * 100):
sold_out = True
exit_reason = 'stop_loss'
triggered = True
if triggered:
exit_date = date
cash = current_value
timeline.append({
'date': date,
'invested': round(total_invested, 2),
'shares': 0,
'nav': round(nav, 4),
'value': round(cash, 2),
'return': round(cash - total_invested, 2),
'return_rate': round((cash - total_invested) / total_invested * 100, 2),
'is_investment_day': is_invest_day,
'status': 'sold',
'exit_reason': exit_reason
})
continue
timeline.append({
'date': date,
'invested': round(total_invested, 2),
'shares': round(total_shares, 4),
'nav': round(nav, 4),
'value': round(current_value, 2),
'return': round(total_return, 2),
'return_rate': round(return_rate, 2),
'is_investment_day': is_invest_day,
'status': 'holding'
})
# 计算汇总指标
if len(timeline) == 0:
return {'error': 'No data to backtest'}
final_record = timeline[-1]
# 计算最大回撤
max_drawdown = 0
peak_value = 0
for record in timeline:
value = record['value']
if value > peak_value:
peak_value = value
if peak_value > 0:
drawdown = (peak_value - value) / peak_value * 100
if drawdown > max_drawdown:
max_drawdown = drawdown
# 计算年化收益率
start_date = datetime.strptime(timeline[0]['date'], '%Y-%m-%d')
end_date = datetime.strptime(timeline[-1]['date'], '%Y-%m-%d')
days = (end_date - start_date).days
years = days / 365.25
total_return_rate = final_record['return_rate'] / 100
annual_return = 0
if years > 0 and total_return_rate > -1:
annual_return = (pow(1 + total_return_rate, 1 / years) - 1) * 100
# 计算夏普比率简化版假设无风险利率2%
returns = []
for i in range(1, len(timeline)):
if timeline[i-1]['value'] > 0:
daily_return = (timeline[i]['value'] - timeline[i-1]['value']) / timeline[i-1]['value']
returns.append(daily_return)
sharpe_ratio = 0
if len(returns) > 0:
mean_return = sum(returns) / len(returns)
if len(returns) > 1:
variance = sum((r - mean_return) ** 2 for r in returns) / (len(returns) - 1)
std_dev = math.sqrt(variance)
if std_dev > 0:
# 年化夏普比率
risk_free_rate = 0.02 / 252 # 日无风险利率
sharpe_ratio = (mean_return - risk_free_rate) / std_dev * math.sqrt(252)
summary = {
'total_invested': round(final_record['invested'], 2),
'final_value': round(final_record['value'], 2),
'total_return': round(final_record['return'], 2),
'return_rate': round(final_record['return_rate'], 2),
'annual_return': round(annual_return, 2),
'max_drawdown': round(-max_drawdown, 2),
'sharpe_ratio': round(sharpe_ratio, 2),
'investment_count': len(investment_dates) + (1 if initial_amount > 0 else 0),
'days': days,
'exit_reason': exit_reason,
'exit_date': exit_date
}
return {
'summary': summary,
'timeline': timeline
}
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000) app.run(debug=True, host='0.0.0.0', port=5000)

View File

@@ -31,6 +31,13 @@
> >
📈 基金对比 📈 基金对比
</button> </button>
<button
class="mode-btn"
:class="{ active: viewMode === 'backtest' }"
@click="viewMode = 'backtest'"
>
💰 定投回测
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -67,6 +74,13 @@
/> />
</template> </template>
<!-- 回测模式 -->
<template v-else-if="viewMode === 'backtest'">
<FundBacktest
:fundCode="selectedFundCode"
/>
</template>
<!-- 详情模式 --> <!-- 详情模式 -->
<template v-else> <template v-else>
<FundSearch @fund-selected="handleFundSelected" /> <FundSearch @fund-selected="handleFundSelected" />
@@ -138,6 +152,7 @@ import FundDetail from './components/FundDetail.vue'
import FundWatchlist from './components/FundWatchlist.vue' import FundWatchlist from './components/FundWatchlist.vue'
import FundComparison from './components/FundComparison.vue' import FundComparison from './components/FundComparison.vue'
import FundScreening from './components/FundScreening.vue' import FundScreening from './components/FundScreening.vue'
import FundBacktest from './components/FundBacktest.vue'
export default { export default {
name: 'App', name: 'App',
@@ -146,7 +161,8 @@ export default {
FundDetail, FundDetail,
FundWatchlist, FundWatchlist,
FundComparison, FundComparison,
FundScreening FundScreening,
FundBacktest
}, },
setup() { setup() {
const selectedFundCode = ref('') const selectedFundCode = ref('')
@@ -154,8 +170,12 @@ export default {
const viewMode = ref('detail') // 'detail', 'screening' 或 'compare' const viewMode = ref('detail') // 'detail', 'screening' 或 'compare'
const compareFunds = ref([]) // 用于对比的基金列表 const compareFunds = ref([]) // 用于对比的基金列表
const handleFundSelected = (fundCode) => { const handleFundSelected = (fundOrCode) => {
selectedFundCode.value = fundCode if (fundOrCode && typeof fundOrCode === 'object') {
selectedFundCode.value = fundOrCode.CODE || fundOrCode.fund_code || fundOrCode.code
} else {
selectedFundCode.value = fundOrCode
}
} }
// 从筛选页面查看基金详情 // 从筛选页面查看基金详情

File diff suppressed because it is too large Load Diff

View File

@@ -135,7 +135,8 @@ export default {
}, },
selectFund(fund) { selectFund(fund) {
this.$emit('fund-selected', fund.CODE) // 传递完整的基金对象,以便接收方获取更多信息(如名称)
this.$emit('fund-selected', fund)
this.searchResults = [] this.searchResults = []
this.searchKeyword = '' this.searchKeyword = ''
} }

View File

@@ -180,4 +180,12 @@ export const screeningAPI = {
} }
} }
// ==================== 基金回测 API ====================
export const backtestAPI = {
// 定投回测
fixedInvestment(data) {
return api.post('/backtest/fixed-investment', data)
}
}
export default api export default api