Files
WarrenZhou e28bfec957 Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
2026-08-17 20:23:22 +08:00

167 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package fallback 实现 datasource.MarketDataSource 接口,提供数据源主备切换能力。
//
// 主数据源(如东财)用于实时行情和历史K线;当主数据源历史K线请求失败时,
// 自动切换到备用数据源(如腾讯),确保数据入库和回测的可用性。
// 实时行情(FetchConvertibles)支持主备切换:主数据源失败时,
// 走"东财 datacenter 列表 + 腾讯实时报价"的组合策略补充实时价格。
//
// 为提高数据源故障时的整体效率,fallback 内置熔断机制:主数据源K线连续
// 失败达到阈值后,进入熔断窗口,期间直接使用备用数据源,不再尝试主数据源;
// 熔断窗口结束后自动放行一次探测,成功则解除熔断,恢复主备切换。
package fallback
import (
"context"
"fmt"
"log"
"sync"
"time"
"stock-analysis/internal/datasource"
"stock-analysis/internal/datasource/eastmoney"
"stock-analysis/internal/datasource/tencent"
"stock-analysis/internal/model"
)
const (
// circuitThreshold 主数据源连续失败达到该次数后触发熔断。
circuitThreshold = 3
// circuitCooldown 熔断持续时间,之后放行一次探测请求。
circuitCooldown = 10 * time.Minute
)
// New 创建主备切换数据源。
// primary 为主数据源(如东财),secondary 为备用数据源(如腾讯)。
func New(primary, secondary datasource.MarketDataSource) datasource.MarketDataSource {
return &dataSource{
primary: primary,
secondary: secondary,
}
}
// dataSource 包装主备两个数据源,实现 MarketDataSource 接口。
type dataSource struct {
primary datasource.MarketDataSource
secondary datasource.MarketDataSource
mu sync.Mutex
failCount int // 主数据源连续失败次数
openSince time.Time // 熔断开启时间(open=true 时有效)
open bool // 熔断是否开启
}
// FetchConvertibles 拉取全量可转债实时行情:先尝试主数据源 push2 接口,
// 失败时走"东财 datacenter 列表 + 腾讯批量实时报价"的组合策略补充实时价格。
//
// 主数据源 push2 故障是常见场景(东财对该集群限流较严),datacenter 与 push2 分属
// 不同集群,通常仍可用,故组合策略能保证实时行情可用性。
func (d *dataSource) FetchConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) {
bonds, err := d.primary.FetchConvertibles(ctx)
if err == nil {
return bonds, nil
}
log.Printf("[fallback] 主数据源实时行情失败: %v,切换到组合策略(东财列表 + 腾讯报价)", err)
return d.fetchConvertiblesViaCombo(ctx)
}
// fetchConvertiblesViaCombo 组合策略实时行情:
// 1. 东财 datacenter 拉转债列表(含正股代码、初始转股价、上市日期)
// 2. 腾讯批量实时接口(qt.gtimg.cn)补充实时价格和涨跌幅
// 3. 过滤已退市转债(实时价格为 0)
func (d *dataSource) fetchConvertiblesViaCombo(ctx context.Context) ([]model.ConvertibleBond, error) {
em := eastmoney.NewClient()
bonds, err := em.FetchAllBondCodes(ctx)
if err != nil {
return nil, fmt.Errorf("datacenter 拉取列表失败: %w", err)
}
if len(bonds) == 0 {
return nil, fmt.Errorf("datacenter 返回空列表")
}
log.Printf("[fallback] datacenter 拉取到 %d 只转债,开始腾讯实时报价", len(bonds))
if err := tencent.FetchQuotes(ctx, bonds); err != nil {
// 腾讯报价失败不致命,已退市转债会被 price=0 过滤
log.Printf("[fallback] 腾讯实时报价异常: %v", err)
}
// 过滤已退市转债(实时价格为 0),与项目硬约束一致
out := make([]model.ConvertibleBond, 0, len(bonds))
for _, b := range bonds {
if b.Price > 0 {
out = append(out, b)
}
}
log.Printf("[fallback] 组合策略完成:有效转债 %d 只(已过滤退市 %d 只)", len(out), len(bonds)-len(out))
return out, nil
}
// FetchKline 获取历史K线:先尝试主数据源,失败后自动切换到备用数据源。
// 熔断开启时直接使用备用数据源。
func (d *dataSource) FetchKline(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
// 熔断开启:直接走备用数据源
if d.circuitOpen() {
return d.fetchSecondary(ctx, code, klt, beg, end)
}
klines, err := d.primary.FetchKline(ctx, code, klt, beg, end)
if err == nil {
// 主数据源恢复,重置失败计数
d.recordResult(true)
return klines, nil
}
log.Printf("[fallback] 主数据源 K线失败 (%s %s %s~%s): %v,切换到备用数据源", code, klt, beg, end, err)
d.recordResult(false)
return d.fetchSecondary(ctx, code, klt, beg, end)
}
// fetchSecondary 从备用数据源获取K线并记录错误。
func (d *dataSource) fetchSecondary(ctx context.Context, code, klt, beg, end string) ([]model.Kline, error) {
klines, err := d.secondary.FetchKline(ctx, code, klt, beg, end)
if err != nil {
return nil, fmt.Errorf("备用数据源: %w", err)
}
return klines, nil
}
// circuitOpen 判断是否处于熔断开启状态;熔断超时后自动放行探测。
func (d *dataSource) circuitOpen() bool {
d.mu.Lock()
defer d.mu.Unlock()
if !d.open {
return false
}
if time.Since(d.openSince) >= circuitCooldown {
// 熔断窗口结束,放行一次探测请求
d.open = false
d.failCount = 0
log.Printf("[fallback] 熔断窗口结束,放行主数据源探测")
return false
}
return true
}
// recordResult 记录主数据源请求结果:失败达到阈值时开启熔断。
func (d *dataSource) recordResult(ok bool) {
d.mu.Lock()
defer d.mu.Unlock()
if ok {
d.failCount = 0
if d.open {
d.open = false
log.Printf("[fallback] 主数据源恢复,解除熔断")
}
return
}
d.failCount++
if d.open {
return
}
if d.failCount >= circuitThreshold {
d.open = true
d.openSince = time.Now()
log.Printf("[fallback] 主数据源连续失败 %d 次,开启熔断 %v,直接使用备用数据源", d.failCount, circuitCooldown)
}
}