feat(indicator): ports + 骨架 + 完整测试 table(红灯)
为什么:先把测试与骨架同时落地,让"红灯先行"作为 commit 3 实现的
靶子。骨架函数全部返回零值,所有断言失败但无编译错误也无 panic。
变更:
- ports.go 新增 IndicatorComputer 接口;接收已 fetch 的 slice、
避免与 MarketContextUsecase 重复 IO(G1 + G12)
- internal/usecase/indicator.go:常量、pivotPoint、9 个 helper +
Compute 全部签名就位,实现先返零值
- internal/usecase/indicator_test.go:parsePrice/formatPrice/
percentile/medianFloat/pivot{Highs,Lows}/clusterLevels/
rangeHighLow/longShortCrossings/Compute 全部 table-driven
覆盖(含 V-shape、双顶 0.18%/0.45%、三聚类、N=1/2/20 percentile、
穿越保留 N、malformed 跳过等)
- go.mod 把 stretchr/testify v1.8.1 从 indirect 提升为 direct
This commit is contained in:
134
internal/usecase/indicator.go
Normal file
134
internal/usecase/indicator.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Package usecase: indicator.go 实现技术指标计算(Milestone 6)。
|
||||
//
|
||||
// 范围(v2 首版):
|
||||
// - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类
|
||||
// - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值
|
||||
// - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数
|
||||
//
|
||||
// 数值边界:本文件内部允许 transient float64,但输出到 entity 前
|
||||
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
|
||||
// 与守卫 G12。
|
||||
//
|
||||
// 算法参数固定为常量;不读取 config——Milestone 6 不引入参数面。
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"cryptoHermes/internal/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
// Pivot 窗口:左右各 5 根 K 线
|
||||
pivotLeft = 5
|
||||
pivotRight = 5
|
||||
|
||||
// 价格聚类阈值:两个 pivot 价位相对距离 < 0.3% → 视为同一 level
|
||||
clusterPctThreshold = 0.003
|
||||
|
||||
// LongShortLine 保留近 N 次穿越
|
||||
longShortCrossKeep = 20
|
||||
|
||||
// Range 百分位
|
||||
rangePercentileHi = 0.95
|
||||
rangePercentileLo = 0.05
|
||||
|
||||
// TechnicalLevel.Source 值
|
||||
sourceSupportPivot = "pivot_low"
|
||||
sourceResistancePivot = "pivot_high"
|
||||
)
|
||||
|
||||
// IndicatorUsecase 实现 IndicatorComputer 接口。
|
||||
// 零状态结构体——所有方法纯函数。
|
||||
type IndicatorUsecase struct{}
|
||||
|
||||
// NewIndicatorUsecase 构造器;无依赖。
|
||||
func NewIndicatorUsecase() *IndicatorUsecase { return &IndicatorUsecase{} }
|
||||
|
||||
// pivotPoint 是 pivot 检测的内部中间结构,价格已经被 parse 成 float64。
|
||||
type pivotPoint struct {
|
||||
Price float64
|
||||
Index int
|
||||
}
|
||||
|
||||
// Compute 是 IndicatorComputer 的唯一对外方法。
|
||||
// klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。
|
||||
// longShort 同样升序;可能为空。
|
||||
//
|
||||
// 当前实现为骨架(commit 2):返回零值结构,让 indicator_test.go 全红。
|
||||
// commit 3 填实算法。
|
||||
func (u *IndicatorUsecase) Compute(
|
||||
klines []entity.Kline,
|
||||
longShort []entity.LongShortRatio,
|
||||
) entity.TechnicalStructure {
|
||||
_ = klines
|
||||
_ = longShort
|
||||
return entity.TechnicalStructure{
|
||||
Support: []entity.TechnicalLevel{},
|
||||
Resistance: []entity.TechnicalLevel{},
|
||||
}
|
||||
}
|
||||
|
||||
// parsePrice 用 strconv 把 string 价格解析成 float64。
|
||||
// 失败(空 / NaN / 非数字)返回 ok=false,调用者应跳过该值不 panic。
|
||||
func parsePrice(s string) (float64, bool) {
|
||||
_ = sort.Float64s // keep import; will be used in commit 3
|
||||
_ = strconv.Itoa
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// formatPrice 把 transient float64 转回 string。
|
||||
// 用 'f' / prec=-1 / bitSize=64 让 FormatFloat 给出最短可往返表示。
|
||||
func formatPrice(f float64) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pivotHighs 在 klines 上扫描,返回所有 left=L、right=R 的 swing high。
|
||||
// swing high 条件:klines[i].High 严格大于 [i-L, i-1] 和 [i+1, i+R] 内所有 high。
|
||||
// 边界:i < L 或 i > len-R-1 跳过。
|
||||
func pivotHighs(klines []entity.Kline, left, right int) []pivotPoint {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pivotLows 对称镜像 pivotHighs,按 .Low 找局部低点。
|
||||
func pivotLows(klines []entity.Kline, left, right int) []pivotPoint {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clusterLevels 把 pivot 点按价格相对距离聚合到同一 level。
|
||||
// 算法:对 points 按价格升序排序;从最小往上扫,若当前点价格与当前 cluster
|
||||
// 的均价相对距离 < pctThreshold,则合并;否则起新 cluster。
|
||||
// 每个 cluster 输出 TechnicalLevel{Price: formatPrice(均价), Strength: strconv.Itoa(len), Source: source}。
|
||||
// 输出按 Strength 降序、Price 降序(同 Strength 时高价在前)。
|
||||
func clusterLevels(points []pivotPoint, pctThreshold float64, source string) []entity.TechnicalLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
// percentile 在升序排好的 sorted 上算线性插值的 p 分位(p∈[0,1])。
|
||||
// 经典 type 7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。
|
||||
// n==0 返回 0;n==1 返回该唯一值。
|
||||
func percentile(sortedAsc []float64, p float64) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// rangeHighLow 计算 RangeHigh(P95)和 RangeLow(P5)。
|
||||
// 解析失败的价位被跳过,不污染百分位计算。
|
||||
// klines 为空时返回 (nil, nil)。
|
||||
func rangeHighLow(klines []entity.Kline) (high *string, low *string) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// longShortCrossings 在 ls 上扫描 ratio == 1.0 的穿越点。
|
||||
// "穿越" 定义:相邻两条 ratio 一上一下穿过 1.0(含相等边界,但单边贴 1.0 不算)。
|
||||
// 对每个穿越点,按时间戳找最近的 kline,用 (open+close)/2 作为穿越价位估计。
|
||||
// 保留最近 keepN 次穿越的价位,取中位数;无穿越或全部 parse 失败 → nil。
|
||||
func longShortCrossings(ls []entity.LongShortRatio, klines []entity.Kline, keepN int) *string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// medianFloat 计算切片中位数;空切片返回 0。会改写传入 slice(内部 sort)。
|
||||
// 调用者若不希望被改,应先 copy。
|
||||
func medianFloat(xs []float64) float64 {
|
||||
return 0
|
||||
}
|
||||
451
internal/usecase/indicator_test.go
Normal file
451
internal/usecase/indicator_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"cryptoHermes/internal/entity"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---- 测试辅助 ----------------------------------------------------------
|
||||
|
||||
// mkKline 构造一根 1h K 线;只填指标关心的字段。
|
||||
// openTime 单位毫秒;hour 序号映射到 1h 时基。
|
||||
func mkKline(hourIdx int64, open, high, low, close string) entity.Kline {
|
||||
const hourMs = int64(3600 * 1000)
|
||||
return entity.Kline{
|
||||
Symbol: "BTCUSDT",
|
||||
Interval: "1h",
|
||||
OpenTime: hourIdx * hourMs,
|
||||
CloseTime: hourIdx*hourMs + hourMs - 1,
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: close,
|
||||
IsClosed: true,
|
||||
}
|
||||
}
|
||||
|
||||
func mkLSR(hourIdx int64, ratio string) entity.LongShortRatio {
|
||||
const hourMs = int64(3600 * 1000)
|
||||
return entity.LongShortRatio{
|
||||
Symbol: "BTCUSDT",
|
||||
Period: "1h",
|
||||
RatioType: entity.RatioTypeGlobalAccount,
|
||||
Timestamp: hourIdx * hourMs,
|
||||
LongShortRatio: ratio,
|
||||
}
|
||||
}
|
||||
|
||||
// 单调上升序列:从 baseHigh 起,每根 K 线 high/low/close 各 +stepUSD。
|
||||
// 共 n 根,hourIdx 从 0 起。
|
||||
func ascending(n int, baseLow, step float64) []entity.Kline {
|
||||
out := make([]entity.Kline, n)
|
||||
for i := 0; i < n; i++ {
|
||||
lo := baseLow + float64(i)*step
|
||||
hi := lo + step
|
||||
mid := (lo + hi) / 2
|
||||
out[i] = mkKline(int64(i),
|
||||
strconv.FormatFloat(lo, 'f', -1, 64),
|
||||
strconv.FormatFloat(hi, 'f', -1, 64),
|
||||
strconv.FormatFloat(lo, 'f', -1, 64),
|
||||
strconv.FormatFloat(mid, 'f', -1, 64),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func descending(n int, baseHigh, step float64) []entity.Kline {
|
||||
out := make([]entity.Kline, n)
|
||||
for i := 0; i < n; i++ {
|
||||
hi := baseHigh - float64(i)*step
|
||||
lo := hi - step
|
||||
mid := (lo + hi) / 2
|
||||
out[i] = mkKline(int64(i),
|
||||
strconv.FormatFloat(hi, 'f', -1, 64),
|
||||
strconv.FormatFloat(hi, 'f', -1, 64),
|
||||
strconv.FormatFloat(lo, 'f', -1, 64),
|
||||
strconv.FormatFloat(mid, 'f', -1, 64),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- parsePrice -------------------------------------------------------
|
||||
|
||||
func TestParsePrice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"normal", "108000.12", 108000.12, true},
|
||||
{"zero", "0", 0, true},
|
||||
{"negative", "-1.5", -1.5, true},
|
||||
{"scientific", "1e3", 1000, true},
|
||||
{"empty", "", 0, false},
|
||||
{"non-numeric", "abc", 0, false},
|
||||
{"whitespace", " 1.0 ", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := parsePrice(c.in)
|
||||
require.Equal(t, c.ok, ok, "ok mismatch for %q", c.in)
|
||||
if c.ok {
|
||||
require.InDelta(t, c.want, got, 1e-9)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- formatPrice ------------------------------------------------------
|
||||
|
||||
func TestFormatPrice(t *testing.T) {
|
||||
require.Equal(t, "0", formatPrice(0))
|
||||
require.Equal(t, "1.5", formatPrice(1.5))
|
||||
require.Equal(t, "108000.12", formatPrice(108000.12))
|
||||
require.Equal(t, "-3", formatPrice(-3))
|
||||
}
|
||||
|
||||
// ---- percentile -------------------------------------------------------
|
||||
|
||||
func TestPercentile(t *testing.T) {
|
||||
// type-7 linear interpolation (NumPy default)
|
||||
require.Equal(t, 0.0, percentile(nil, 0.5), "empty → 0")
|
||||
require.Equal(t, 42.0, percentile([]float64{42}, 0.95), "N=1")
|
||||
// N=2: P95: rank=(2-1)*0.95=0.95, between [0]=10 and [1]=20 → 10 + 0.95*10 = 19.5
|
||||
require.InDelta(t, 19.5, percentile([]float64{10, 20}, 0.95), 1e-9)
|
||||
require.InDelta(t, 10.5, percentile([]float64{10, 20}, 0.05), 1e-9)
|
||||
// N=20: rank=19*0.95=18.05, between [18] and [19]
|
||||
xs := make([]float64, 20)
|
||||
for i := range xs {
|
||||
xs[i] = float64(i + 1) // 1..20
|
||||
}
|
||||
// rank=18.05 → 19 + 0.05*(20-19) = 19.05
|
||||
require.InDelta(t, 19.05, percentile(xs, 0.95), 1e-9)
|
||||
// rank=0.95 → 1 + 0.95*(2-1) = 1.95
|
||||
require.InDelta(t, 1.95, percentile(xs, 0.05), 1e-9)
|
||||
}
|
||||
|
||||
// ---- medianFloat ------------------------------------------------------
|
||||
|
||||
func TestMedianFloat(t *testing.T) {
|
||||
require.Equal(t, 0.0, medianFloat(nil))
|
||||
require.Equal(t, 5.0, medianFloat([]float64{5}))
|
||||
require.InDelta(t, 7.5, medianFloat([]float64{5, 10}), 1e-9)
|
||||
require.InDelta(t, 5.0, medianFloat([]float64{1, 5, 9}), 1e-9)
|
||||
require.InDelta(t, 4.0, medianFloat([]float64{9, 1, 5, 3}), 1e-9) // unsorted input
|
||||
}
|
||||
|
||||
// ---- pivotHighs / pivotLows ------------------------------------------
|
||||
|
||||
func TestPivot_EmptyAndShortInputs(t *testing.T) {
|
||||
require.Empty(t, pivotHighs(nil, 5, 5))
|
||||
require.Empty(t, pivotLows(nil, 5, 5))
|
||||
require.Empty(t, pivotHighs(ascending(5, 100, 1), 5, 5), "N < 2L+1")
|
||||
require.Empty(t, pivotLows(ascending(10, 100, 1), 5, 5), "N < 2L+1")
|
||||
}
|
||||
|
||||
func TestPivot_MonotonicNoPivots(t *testing.T) {
|
||||
asc := ascending(30, 100, 10)
|
||||
require.Empty(t, pivotHighs(asc, 5, 5), "ascending has no swing high in interior")
|
||||
require.Empty(t, pivotLows(asc, 5, 5), "ascending has no swing low in interior")
|
||||
|
||||
desc := descending(30, 100, 1)
|
||||
require.Empty(t, pivotHighs(desc, 5, 5))
|
||||
require.Empty(t, pivotLows(desc, 5, 5))
|
||||
}
|
||||
|
||||
func TestPivot_VShape(t *testing.T) {
|
||||
// 构造 V 形:前 10 根 high 递减、后 10 根递增;中间最低点应为唯一 pivotLow
|
||||
klines := make([]entity.Kline, 21)
|
||||
for i := 0; i < 21; i++ {
|
||||
// price 形态:100 -> 90 (index 10) -> 100
|
||||
var p float64
|
||||
if i <= 10 {
|
||||
p = 100 - float64(i)*1.0
|
||||
} else {
|
||||
p = 90 + float64(i-10)*1.0
|
||||
}
|
||||
hi := p + 0.5
|
||||
lo := p
|
||||
mid := p + 0.25
|
||||
klines[i] = mkKline(int64(i),
|
||||
strconv.FormatFloat(p, 'f', -1, 64),
|
||||
strconv.FormatFloat(hi, 'f', -1, 64),
|
||||
strconv.FormatFloat(lo, 'f', -1, 64),
|
||||
strconv.FormatFloat(mid, 'f', -1, 64),
|
||||
)
|
||||
}
|
||||
highs := pivotHighs(klines, 5, 5)
|
||||
lows := pivotLows(klines, 5, 5)
|
||||
require.Empty(t, highs, "V-shape has no pivot high")
|
||||
require.Len(t, lows, 1, "V-shape has exactly one pivot low")
|
||||
require.Equal(t, 10, lows[0].Index)
|
||||
require.InDelta(t, 90.0, lows[0].Price, 1e-9)
|
||||
}
|
||||
|
||||
func TestPivot_DoubleTop(t *testing.T) {
|
||||
// 两个明确的 pivot high:index 7 和 index 15
|
||||
// 用价格谷-峰-谷-峰-谷 结构
|
||||
klines := make([]entity.Kline, 23)
|
||||
// 默认平的低位 100
|
||||
for i := range klines {
|
||||
klines[i] = mkKline(int64(i), "100", "100.5", "99.5", "100")
|
||||
}
|
||||
// peak1 at i=7, price 110
|
||||
klines[7] = mkKline(7, "109", "110", "108", "109.5")
|
||||
// peak2 at i=15, price 110.2 (0.18% 距离,<0.3% 阈值)
|
||||
klines[15] = mkKline(15, "109", "110.2", "108", "109.5")
|
||||
|
||||
highs := pivotHighs(klines, 5, 5)
|
||||
require.Len(t, highs, 2)
|
||||
require.Equal(t, 7, highs[0].Index)
|
||||
require.Equal(t, 15, highs[1].Index)
|
||||
}
|
||||
|
||||
// ---- clusterLevels ----------------------------------------------------
|
||||
|
||||
func TestClusterLevels_Empty(t *testing.T) {
|
||||
require.Empty(t, clusterLevels(nil, 0.003, sourceResistancePivot))
|
||||
}
|
||||
|
||||
func TestClusterLevels_DoubleTopWithinThreshold(t *testing.T) {
|
||||
// 两个点相差 0.18%(< 0.3%)→ 合并为 1 个 level,Strength="2"
|
||||
pts := []pivotPoint{
|
||||
{Price: 110.0, Index: 7},
|
||||
{Price: 110.2, Index: 15},
|
||||
}
|
||||
out := clusterLevels(pts, 0.003, sourceResistancePivot)
|
||||
require.Len(t, out, 1)
|
||||
require.Equal(t, "2", out[0].Strength)
|
||||
require.Equal(t, sourceResistancePivot, out[0].Source)
|
||||
// 均价 = 110.1
|
||||
p, ok := parsePrice(out[0].Price)
|
||||
require.True(t, ok)
|
||||
require.InDelta(t, 110.1, p, 1e-6)
|
||||
}
|
||||
|
||||
func TestClusterLevels_DoubleTopOutsideThreshold(t *testing.T) {
|
||||
// 两个点相差 0.45%(> 0.3%)→ 不合并
|
||||
pts := []pivotPoint{
|
||||
{Price: 110.0, Index: 7},
|
||||
{Price: 110.5, Index: 15},
|
||||
}
|
||||
out := clusterLevels(pts, 0.003, sourceResistancePivot)
|
||||
require.Len(t, out, 2)
|
||||
require.Equal(t, "1", out[0].Strength)
|
||||
require.Equal(t, "1", out[1].Strength)
|
||||
}
|
||||
|
||||
func TestClusterLevels_TripleCluster(t *testing.T) {
|
||||
pts := []pivotPoint{
|
||||
{Price: 100.0},
|
||||
{Price: 100.15},
|
||||
{Price: 100.25},
|
||||
}
|
||||
// 100/100.25 相差 0.25%,都在阈值内
|
||||
out := clusterLevels(pts, 0.003, sourceResistancePivot)
|
||||
require.Len(t, out, 1)
|
||||
require.Equal(t, "3", out[0].Strength)
|
||||
}
|
||||
|
||||
// ---- rangeHighLow -----------------------------------------------------
|
||||
|
||||
func TestRangeHighLow_Empty(t *testing.T) {
|
||||
hi, lo := rangeHighLow(nil)
|
||||
require.Nil(t, hi)
|
||||
require.Nil(t, lo)
|
||||
}
|
||||
|
||||
func TestRangeHighLow_SingleCandle(t *testing.T) {
|
||||
k := []entity.Kline{mkKline(0, "100", "105", "95", "102")}
|
||||
hi, lo := rangeHighLow(k)
|
||||
require.NotNil(t, hi)
|
||||
require.NotNil(t, lo)
|
||||
require.Equal(t, "105", *hi, "P95 of single value is the value")
|
||||
require.Equal(t, "95", *lo)
|
||||
}
|
||||
|
||||
func TestRangeHighLow_Twenty(t *testing.T) {
|
||||
// highs 1..20、lows 1..20;P95(high)=19.05、P5(low)=1.95
|
||||
klines := make([]entity.Kline, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
v := float64(i + 1)
|
||||
klines[i] = mkKline(int64(i),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
)
|
||||
}
|
||||
hi, lo := rangeHighLow(klines)
|
||||
require.NotNil(t, hi)
|
||||
require.NotNil(t, lo)
|
||||
hiF, _ := parsePrice(*hi)
|
||||
loF, _ := parsePrice(*lo)
|
||||
require.InDelta(t, 19.05, hiF, 1e-6)
|
||||
require.InDelta(t, 1.95, loF, 1e-6)
|
||||
}
|
||||
|
||||
func TestRangeHighLow_SkipsMalformed(t *testing.T) {
|
||||
klines := []entity.Kline{
|
||||
mkKline(0, "100", "abc", "99", "100"), // high malformed → skipped
|
||||
mkKline(1, "100", "200", "xyz", "100"), // low malformed → skipped
|
||||
mkKline(2, "100", "150", "50", "100"), // good
|
||||
}
|
||||
hi, lo := rangeHighLow(klines)
|
||||
require.NotNil(t, hi)
|
||||
require.NotNil(t, lo)
|
||||
// 有效 highs = [200, 150],有效 lows = [99, 50]
|
||||
hiF, _ := parsePrice(*hi)
|
||||
loF, _ := parsePrice(*lo)
|
||||
// P95 of {150,200}: sorted=[150,200], rank=0.95 → 150 + 0.95*50 = 197.5
|
||||
// P5 of {50,99}: rank=0.05 → 50 + 0.05*49 = 52.45
|
||||
require.InDelta(t, 197.5, hiF, 1e-6)
|
||||
require.InDelta(t, 52.45, loF, 1e-6)
|
||||
}
|
||||
|
||||
// ---- longShortCrossings -----------------------------------------------
|
||||
|
||||
func TestLongShortCrossings_Empty(t *testing.T) {
|
||||
require.Nil(t, longShortCrossings(nil, nil, 20))
|
||||
require.Nil(t, longShortCrossings([]entity.LongShortRatio{mkLSR(0, "1.5")}, nil, 20))
|
||||
}
|
||||
|
||||
func TestLongShortCrossings_AllAbove(t *testing.T) {
|
||||
ls := []entity.LongShortRatio{
|
||||
mkLSR(0, "1.2"), mkLSR(1, "1.3"), mkLSR(2, "1.5"),
|
||||
}
|
||||
klines := []entity.Kline{
|
||||
mkKline(0, "100", "101", "99", "100"),
|
||||
mkKline(1, "100", "101", "99", "100"),
|
||||
mkKline(2, "100", "101", "99", "100"),
|
||||
}
|
||||
require.Nil(t, longShortCrossings(ls, klines, 20))
|
||||
}
|
||||
|
||||
func TestLongShortCrossings_AllBelow(t *testing.T) {
|
||||
ls := []entity.LongShortRatio{
|
||||
mkLSR(0, "0.5"), mkLSR(1, "0.7"), mkLSR(2, "0.9"),
|
||||
}
|
||||
klines := []entity.Kline{
|
||||
mkKline(0, "100", "101", "99", "100"),
|
||||
mkKline(1, "100", "101", "99", "100"),
|
||||
mkKline(2, "100", "101", "99", "100"),
|
||||
}
|
||||
require.Nil(t, longShortCrossings(ls, klines, 20))
|
||||
}
|
||||
|
||||
func TestLongShortCrossings_SingleCrossing(t *testing.T) {
|
||||
// LSR:0.9 → 1.1(穿越发生在 index=1)
|
||||
ls := []entity.LongShortRatio{
|
||||
mkLSR(0, "0.9"),
|
||||
mkLSR(1, "1.1"),
|
||||
}
|
||||
klines := []entity.Kline{
|
||||
mkKline(0, "100", "101", "99", "100"),
|
||||
mkKline(1, "120", "121", "119", "120"),
|
||||
}
|
||||
got := longShortCrossings(ls, klines, 20)
|
||||
require.NotNil(t, got)
|
||||
// 穿越价位估计:取 ls[1] 时刻 kline 的 (open+close)/2 = 120
|
||||
v, ok := parsePrice(*got)
|
||||
require.True(t, ok)
|
||||
require.InDelta(t, 120.0, v, 1e-6)
|
||||
}
|
||||
|
||||
func TestLongShortCrossings_MultipleCrossings(t *testing.T) {
|
||||
// 来回穿越多次:取近 N 次的价位中位数
|
||||
// 序列:0.9, 1.1, 0.95, 1.05 → 三次穿越(i=1, i=2, i=3)
|
||||
ls := []entity.LongShortRatio{
|
||||
mkLSR(0, "0.9"),
|
||||
mkLSR(1, "1.1"),
|
||||
mkLSR(2, "0.95"),
|
||||
mkLSR(3, "1.05"),
|
||||
}
|
||||
klines := []entity.Kline{
|
||||
mkKline(0, "100", "101", "99", "100"),
|
||||
mkKline(1, "110", "111", "109", "110"), // 穿越价 110
|
||||
mkKline(2, "120", "121", "119", "120"), // 穿越价 120
|
||||
mkKline(3, "130", "131", "129", "130"), // 穿越价 130
|
||||
}
|
||||
got := longShortCrossings(ls, klines, 20)
|
||||
require.NotNil(t, got)
|
||||
// 中位数 = 120
|
||||
v, ok := parsePrice(*got)
|
||||
require.True(t, ok)
|
||||
require.InDelta(t, 120.0, v, 1e-6)
|
||||
}
|
||||
|
||||
func TestLongShortCrossings_KeepNLimit(t *testing.T) {
|
||||
// 24 个穿越价 1..24,keepN=20 → 保留最近 20 个(价 5..24),中位 14.5
|
||||
ls := []entity.LongShortRatio{}
|
||||
klines := []entity.Kline{}
|
||||
for i := 0; i < 25; i++ {
|
||||
var r string
|
||||
if i%2 == 0 {
|
||||
r = "0.9"
|
||||
} else {
|
||||
r = "1.1"
|
||||
}
|
||||
ls = append(ls, mkLSR(int64(i), r))
|
||||
v := float64(i + 1)
|
||||
klines = append(klines, mkKline(int64(i),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
strconv.FormatFloat(v, 'f', -1, 64),
|
||||
))
|
||||
}
|
||||
got := longShortCrossings(ls, klines, 20)
|
||||
require.NotNil(t, got)
|
||||
// 24 个穿越(i=1..24),保留最近 20 个对应 klines[5..24] → price 6..25
|
||||
// 中位 = (15+16)/2 = 15.5
|
||||
v, ok := parsePrice(*got)
|
||||
require.True(t, ok)
|
||||
require.InDelta(t, 15.5, v, 1e-6)
|
||||
}
|
||||
|
||||
// ---- Compute(端到端编排) --------------------------------------------
|
||||
|
||||
func TestCompute_EmptyInputs(t *testing.T) {
|
||||
u := NewIndicatorUsecase()
|
||||
out := u.Compute(nil, nil)
|
||||
require.NotNil(t, out.Support)
|
||||
require.NotNil(t, out.Resistance)
|
||||
require.Empty(t, out.Support)
|
||||
require.Empty(t, out.Resistance)
|
||||
require.Nil(t, out.RangeHigh)
|
||||
require.Nil(t, out.RangeLow)
|
||||
require.Nil(t, out.LongShortLine)
|
||||
}
|
||||
|
||||
func TestCompute_SmokeWithVShape(t *testing.T) {
|
||||
u := NewIndicatorUsecase()
|
||||
// V-shape 21 根,应有 1 个 support、0 个 resistance、有 range
|
||||
klines := make([]entity.Kline, 21)
|
||||
for i := 0; i < 21; i++ {
|
||||
var p float64
|
||||
if i <= 10 {
|
||||
p = 100 - float64(i)*1.0
|
||||
} else {
|
||||
p = 90 + float64(i-10)*1.0
|
||||
}
|
||||
klines[i] = mkKline(int64(i),
|
||||
strconv.FormatFloat(p, 'f', -1, 64),
|
||||
strconv.FormatFloat(p+0.5, 'f', -1, 64),
|
||||
strconv.FormatFloat(p, 'f', -1, 64),
|
||||
strconv.FormatFloat(p+0.25, 'f', -1, 64),
|
||||
)
|
||||
}
|
||||
out := u.Compute(klines, nil)
|
||||
require.Len(t, out.Support, 1)
|
||||
require.Empty(t, out.Resistance)
|
||||
require.NotNil(t, out.RangeHigh)
|
||||
require.NotNil(t, out.RangeLow)
|
||||
require.Nil(t, out.LongShortLine, "no LSR data → nil")
|
||||
}
|
||||
@@ -49,3 +49,16 @@ type TakerVolumeRepository interface {
|
||||
UpsertMany(ctx context.Context, items []entity.TakerBuySellVolume) error
|
||||
FindRecent(ctx context.Context, symbol, period string, limit int) ([]entity.TakerBuySellVolume, error)
|
||||
}
|
||||
|
||||
// IndicatorComputer 纯函数式接口:接收已经被上层 fetch 的 slice,
|
||||
// 返回填充好的 TechnicalStructure。
|
||||
//
|
||||
// 不持有任何 repo 引用——保住 G1(usecase 不依赖 repo 实现)和
|
||||
// G12(indicator 包零 repo 导入)。MarketContextUsecase 已经并发 fetch
|
||||
// 过 kline 与 long-short ratio,复用结果即可,二次注入仓库引用会
|
||||
// 重复 IO。
|
||||
//
|
||||
// 实现可见 internal/usecase/indicator.go。
|
||||
type IndicatorComputer interface {
|
||||
Compute(klines []entity.Kline, longShort []entity.LongShortRatio) entity.TechnicalStructure
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user