From df849d98b7a2e4f7ab6cddf8fe9dfe6c50dd3dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sun, 17 Aug 2025 22:48:13 +0800 Subject: [PATCH] add sma ema macd --- internal/lib/strategy/ema.go | 145 +++++++++++------ internal/lib/strategy/ema_test.go | 246 ++++++++++++++++++----------- internal/lib/strategy/macd.go | 89 +++++++++++ internal/lib/strategy/macd_test.go | 223 ++++++++++++++++++++++++++ internal/lib/strategy/readme.md | 136 ++++++++++++++++ internal/lib/strategy/sma.go | 123 +++++++++++---- internal/lib/strategy/sma_test.go | 206 ++++++++++++------------ 7 files changed, 885 insertions(+), 283 deletions(-) create mode 100644 internal/lib/strategy/macd.go create mode 100644 internal/lib/strategy/macd_test.go create mode 100644 internal/lib/strategy/readme.md diff --git a/internal/lib/strategy/ema.go b/internal/lib/strategy/ema.go index 62ed8be..7cdb0fa 100644 --- a/internal/lib/strategy/ema.go +++ b/internal/lib/strategy/ema.go @@ -1,66 +1,107 @@ package strategy -import "github.com/shopspring/decimal" +import ( + "sync/atomic" -/* - EMA,全名為指數移動平均線(Exponential Moving Average),用於平滑價格波動,幫助識別市場趨勢。 - 它與簡單移動平均線(SMA)不同,EMA 更注重近期價格,因此對價格變動的反應更迅速,能更快地反映市場趨勢。 + "github.com/shopspring/decimal" +) -EMA 的主要特點和作用: - 更快速的反應: - EMA 比 SMA 更快地反映價格變動,因為它給予近期數據更高的權重。 - 識別趨勢: - 通過平滑價格波動,EMA 有助於識別市場的整體趨勢,判斷是上升趨勢還是下降趨勢。 - 輔助交易決策: - EMA 的使用可以幫助交易者判斷買入和賣出的時機,例如,當股價高於EMA 時,可能被視為買入信號;反之,則可能被視為賣出信號。 - 適合短線交易: - 由於EMA 對價格變動的敏感性,它更適合於短線交易者,能更快地捕捉市場的短期波動。 - EMA 的計算方法: - EMA 的計算涉及一個平滑因子和一個初始值,然後每天更新。 具體公式可以參考專業的金融網站或交易平台提供的資料。 - 總結: - EMA 是一種有用的技術分析工具,尤其適合於快速變動的市場,它可以幫助交易者更好地理解市場趨勢,並制定相應的交易策略 -*/ +// α = 2 / (N+1) 平滑係數 +func alphaFromPeriod(n uint) decimal.Decimal { + return decimal.NewFromInt(2). + Div(decimal.NewFromInt(int64(n)).Add(decimal.NewFromInt(1))) +} + +// ========================= +// EMA(單寫;快照多讀) +// Seed: SMA 作為初始化 +// ========================= + +type emaCore struct { + period uint + alpha decimal.Decimal + + // 狀態(單 writer 更新) + value decimal.Decimal + ready bool + count uint + sum decimal.Decimal // warm-up 期間用於 SMA seed +} + +func newEMACore(period uint) *emaCore { + if period == 0 { + panic("EMA period must be > 0") + } + return &emaCore{ + period: period, + alpha: alphaFromPeriod(period), + } +} + +// Push 僅供單 writer 呼叫 +// 回傳 (當前 EMA, ready) +func (e *emaCore) Push(x decimal.Decimal) (decimal.Decimal, bool) { + e.count++ + + // Warm-up:用 SMA 作為 seed + if !e.ready { + e.sum = e.sum.Add(x) + if e.count == e.period { + e.value = e.sum.Div(decimal.NewFromInt(int64(e.period))) + e.ready = true + } + return e.value, e.ready + } + + oneMinus := decimal.NewFromInt(1).Sub(e.alpha) + e.value = e.value.Mul(oneMinus).Add(x.Mul(e.alpha)) + return e.value, true +} + +// ===== 快照發布(多讀零鎖) ===== + +type EMASnapshot struct { + Period uint + Alpha string // 十進位字串,方便落盤或序列化 + Value decimal.Decimal // 目前 EMA + Ready bool + LastInput decimal.Decimal // 最近一次輸入值 + Count uint // 已處理資料點 +} type EMA struct { - n uint - alp decimal.Decimal // 平滑係數 α = 2 / (n + 1) - val decimal.Decimal // 當前EMA值 - ok bool // 內部旗標,用於判斷是否為第一筆資料 + core *emaCore + last decimal.Decimal + snap atomic.Value // holds EMASnapshot } -// NewEMA 建立EMA計算器 -func NewEMA(n uint) *EMA { - return &EMA{ - n: n, - alp: decimal.NewFromInt(2).Div(decimal.NewFromInt(int64(n + 1))), - ok: false, - } +func NewEMA(period uint) *EMA { + c := newEMACore(period) + e := &EMA{core: c} + e.snap.Store(EMASnapshot{ + Period: period, + Alpha: c.alpha.String(), + }) + return e } -// Push 輸入收盤價,返回當前EMA值 -func (e *EMA) Push(close decimal.Decimal) (decimal.Decimal, bool) { - // 如果 n 無效,永遠回傳無效狀態 - if e.n == 0 { - return decimal.Zero, false +// Update 單 writer 呼叫 +func (e *EMA) Update(x decimal.Decimal) EMASnapshot { + val, ready := e.core.Push(x) + e.last = x + snap := EMASnapshot{ + Period: e.core.period, + Alpha: e.core.alpha.String(), + Value: val, + Ready: ready, + LastInput: x, + Count: e.core.count, } - - if !e.ok { - // 第一筆資料直接當作EMA初始值,並將狀態設為 ok - e.val = close - e.ok = true - } else { - // 後續資料使用 EMA 計算公式 - // EMA = α * close + (1 - α) * prev_EMA - e.val = e.alp.Mul(close).Add(decimal.NewFromInt(1).Sub(e.alp).Mul(e.val)) - } - // EMA 從第一筆資料開始就是有效的 - return e.val, true + e.snap.Store(snap) + return snap } -// GetEMA 取得目前 EMA 值 -func (e *EMA) GetEMA() (decimal.Decimal, bool) { - if !e.ok { - return decimal.Zero, false // 尚未初始化 - } - return e.val, true +// Load 多 reader 零鎖讀 +func (e *EMA) Load() EMASnapshot { + return e.snap.Load().(EMASnapshot) } diff --git a/internal/lib/strategy/ema_test.go b/internal/lib/strategy/ema_test.go index 8da532b..c225a31 100644 --- a/internal/lib/strategy/ema_test.go +++ b/internal/lib/strategy/ema_test.go @@ -1,102 +1,170 @@ package strategy import ( - "github.com/shopspring/decimal" + "sync" "testing" + "time" + + "github.com/shopspring/decimal" ) -// --- EMA 的表格式驅動測試 (新增) --- +func almostEqual(a, b decimal.Decimal, tol float64) bool { + if a.Equal(b) { + return true + } + diff := a.Sub(b).Abs() + return diff.LessThanOrEqual(decimal.NewFromFloat(tol)) +} -func TestEMA(t *testing.T) { - d10 := decimal.NewFromInt(10) - d11 := decimal.NewFromInt(11) - d12 := decimal.NewFromInt(12) - d13 := decimal.NewFromInt(13) - d20 := decimal.NewFromInt(20) - - type pushCheck struct { - wantEMA decimal.Decimal - wantOK bool +// 產生參考 EMA(SMA-seed):前 period 筆做 SMA 當種子,之後用 α 遞迴 +func emaReferenceSMASeed(seq []float64, period uint) []decimal.Decimal { + if period == 0 { + panic("period must be > 0") + } + alpha := decimal.NewFromInt(2). + Div(decimal.NewFromInt(int64(period)).Add(decimal.NewFromInt(1))) + + out := make([]decimal.Decimal, len(seq)) + var sum decimal.Decimal + var ready bool + var emaVal decimal.Decimal + + for i, x := range seq { + px := d(int64(x)) + if !ready { + sum = sum.Add(px) + if uint(i+1) == period { + emaVal = sum.Div(decimal.NewFromInt(int64(period))) // SMA seed + ready = true + } + out[i] = emaVal + continue + } + emaVal = emaVal.Mul(decimal.NewFromInt(1).Sub(alpha)).Add(px.Mul(alpha)) + out[i] = emaVal + } + return out +} + +func TestEMA_WarmupAndStepByStep(t *testing.T) { + type step struct { + in float64 + want float64 + wantReady bool + } + alpha := 2.0 / (3.0 + 1.0) // period=3 => α=0.5 + _ = alpha + + // 序列:1,2,3(seed 完成 => 2.0),下一筆 4 => 0.5*4 + 0.5*2 = 3.0 + steps := []step{ + {in: 1, want: 0, wantReady: false}, // value 在未 ready 前不重要,但實作會回 seed 累積值;這步不檢查值 + {in: 2, want: 0, wantReady: false}, + {in: 3, want: 2.0, wantReady: true}, + {in: 4, want: 3.0, wantReady: true}, } - testCases := []struct { - name string - n uint - inputs []decimal.Decimal - pushChecks []pushCheck - wantFinalEMA decimal.Decimal - wantFinalOK bool - }{ - { - name: "EMA-3 標準計算", - n: 3, // α = 2 / (3 + 1) = 0.5 - inputs: []decimal.Decimal{d10, d11, d12}, - pushChecks: []pushCheck{ - {d10, true}, // 第一次, EMA = 10 - {decimal.NewFromFloat(10.5), true}, // 第二次, 0.5*11 + (1-0.5)*10 = 5.5 + 5 = 10.5 - {decimal.NewFromFloat(11.25), true}, // 第三次, 0.5*12 + (1-0.5)*10.5 = 6 + 5.25 = 11.25 - }, - wantFinalEMA: decimal.NewFromFloat(11.25), - wantFinalOK: true, - }, - { - name: "EMA-1 邊界情況", - n: 1, // α = 2 / (1 + 1) = 1 - inputs: []decimal.Decimal{d10, d13, d11}, - pushChecks: []pushCheck{ - {d10, true}, // 第一次, EMA = 10 - {d13, true}, // 第二次, 1*13 + 0*10 = 13 - {d11, true}, // 第三次, 1*11 + 0*13 = 11 - }, - wantFinalEMA: d11, - wantFinalOK: true, - }, - { - name: "EMA-0 無效情況", - n: 0, - inputs: []decimal.Decimal{d10, d20}, - pushChecks: []pushCheck{ - {decimal.Zero, false}, - {decimal.Zero, false}, - }, - wantFinalEMA: decimal.Zero, - wantFinalOK: false, - }, - { - name: "在空實例上呼叫 GetEMA", - n: 5, - inputs: []decimal.Decimal{}, - pushChecks: []pushCheck{}, - wantFinalEMA: decimal.Zero, - wantFinalOK: false, - }, - } + e := NewEMA(3) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ema := NewEMA(tc.n) - - for i, input := range tc.inputs { - gotEMA, gotOK := ema.Push(input) - if i < len(tc.pushChecks) { - check := tc.pushChecks[i] - if gotOK != check.wantOK { - t.Errorf("Push #%d 的 OK 狀態錯誤: got %v, want %v", i+1, gotOK, check.wantOK) - } - // 使用 String() 進行比較,避免浮點數精度問題 - if gotEMA.String() != check.wantEMA.String() { - t.Errorf("Push #%d 的 EMA 值錯誤: got %s, want %s", i+1, gotEMA.String(), check.wantEMA.String()) - } - } + for i, st := range steps { + got := e.Update(d(int64(st.in))) + // 檢查 ready + if got.Ready != st.wantReady { + t.Fatalf("step %d: ready got=%v want=%v", i, got.Ready, st.wantReady) + } + // 檢查值(只有 ready 後才精準檢查) + if got.Ready { + if !almostEqual(got.Value, d(int64(st.want)), 1e-12) { + t.Fatalf("step %d: value got=%s want=%v", i, got.Value, st.want) } - - finalEMA, finalOK := ema.GetEMA() - if finalOK != tc.wantFinalOK { - t.Errorf("最終 GetEMA 的 OK 狀態錯誤: got %v, want %v", finalOK, tc.wantFinalOK) - } - if finalEMA.String() != tc.wantFinalEMA.String() { - t.Errorf("最終 GetEMA 的 EMA 值錯誤: got %s, want %s", finalEMA.String(), tc.wantFinalEMA.String()) - } - }) + } + // Load() 與 Update() 一致 + ld := e.Load() + if ld.Ready != got.Ready || !ld.Value.Equal(got.Value) { + t.Fatalf("step %d: Load() mismatch", i) + } + } +} + +func TestEMA_MatchesReference_SMASeed(t *testing.T) { + period := uint(10) + seq := []float64{10, 11, 12, 13, 12, 11, 12, 13, 14, 15, 16, 15, 14, 13, 12, 11, 10, 11, 12, 13} + ref := emaReferenceSMASeed(seq, period) + + e := NewEMA(period) + for i, x := range seq { + out := e.Update(d(int64(x))) + // 只有在 ready 後才對齊 ref + if out.Ready { + if !almostEqual(out.Value, ref[i], 1e-9) { + t.Fatalf("i=%d: EMA value got=%s ref=%s", i, out.Value, ref[i]) + } + } else { + // 未 ready 時,檢查準備進度是否合理 + if uint(i+1) == period && !out.Ready { + t.Fatalf("i=%d: should be ready at the period boundary", i) + } + } + } +} + +func TestEMA_PeriodOne_DegenerateCase(t *testing.T) { + // period=1 => α=1,第一筆即 ready,之後永遠等於最新價 + e := NewEMA(1) + seq := []float64{10, 11, 12, 9, 8, 15} + for i, x := range seq { + out := e.Update(d(int64(x))) + if !out.Ready { + t.Fatalf("i=%d: period=1 should be ready immediately", i) + } + if !out.Value.Equal(d(int64(x))) { + t.Fatalf("i=%d: value got=%s want=%v", i, out.Value, x) + } + // Load() 同步 + if !e.Load().Value.Equal(out.Value) { + t.Fatalf("i=%d: Load() mismatch", i) + } + } +} + +func TestEMA_MultiReadersSingleWriter(t *testing.T) { + e := NewEMA(5) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 多 reader:不斷 Load(),不應當出現資料競態或 panic + reader := func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = e.Load() + } + } + } + + // 啟動多個讀者 + for i := 0; i < 8; i++ { + wg.Add(1) + go reader() + } + + // 單 writer:依序更新 + prices := []float64{1, 2, 3, 4, 5, 6, 7} + for _, px := range prices { + e.Update(d(int64(px))) + time.Sleep(1 * time.Millisecond) + } + + // 收攤 + close(stop) + wg.Wait() + + // 最後至少應該是 ready + final := e.Load() + if !final.Ready { + t.Fatalf("final snapshot should be ready, got=%v", final.Ready) } } diff --git a/internal/lib/strategy/macd.go b/internal/lib/strategy/macd.go new file mode 100644 index 0000000..a63360c --- /dev/null +++ b/internal/lib/strategy/macd.go @@ -0,0 +1,89 @@ +package strategy + +import ( + "sync/atomic" + + "github.com/shopspring/decimal" +) + +// ========================= +// MACD(單寫;快照多讀) +// fastEMA / slowEMA 吃「收盤價」;signalEMA 吃「MACDLine」 +// 與 EMA/SMA 一致風格:Update()(單寫)、Load()(多讀) +// ========================= + +// MACDSnapshot 對外發佈的不可變快照 +type MACDSnapshot struct { + Params [3]uint // (fast, slow, signal) + LastPrice decimal.Decimal // 最近一次喂入的收盤價 + MACDLine decimal.Decimal // DIF = fastEMA - slowEMA + SignalLine decimal.Decimal // DEA = EMA(MACDLine, signalPeriod) + Histogram decimal.Decimal // Hist = MACDLine - SignalLine + Ready bool // 三條線皆 ready 才會 true +} + +// MACD 快照發佈器(單寫多讀) +type MACD struct { + // 內部用你已完成的 EMA(其本身就含 SMA-seed 與 ready 狀態) + fastEMA *EMA + slowEMA *EMA + signalEMA *EMA + + fastPeriod, slowPeriod, signalPeriod uint + + last decimal.Decimal // 只作紀錄(debug/觀察用) + snap atomic.Value // holds MACDSnapshot +} + +// NewMACD 建立 MACD(預設 12,26,9;也可自訂) +func NewMACD(fast, slow, signal uint) *MACD { + if !(fast > 0 && slow > 0 && signal > 0) { + panic("MACD periods must be > 0") + } + if fast >= slow { + panic("MACD requires fast < slow (e.g., 12 < 26)") + } + + m := &MACD{ + fastEMA: NewEMA(fast), + slowEMA: NewEMA(slow), + signalEMA: NewEMA(signal), + fastPeriod: fast, + slowPeriod: slow, + signalPeriod: signal, + } + // 初始化一個空快照 + m.snap.Store(MACDSnapshot{Params: [3]uint{fast, slow, signal}}) + return m +} + +// Update 僅供單一 writer 呼叫;回傳並發佈最新快照 +func (m *MACD) Update(close decimal.Decimal) MACDSnapshot { + // 先用 EMA(你已改好的版本)各自更新快、慢線 + fast := m.fastEMA.Update(close) + slow := m.slowEMA.Update(close) + + macdLine := fast.Value.Sub(slow.Value) + + // 信號線吃的是 MACDLine(非收盤價) + sig := m.signalEMA.Update(macdLine) + + ready := fast.Ready && slow.Ready && sig.Ready + + snap := MACDSnapshot{ + Params: [3]uint{m.fastPeriod, m.slowPeriod, m.signalPeriod}, + LastPrice: close, + MACDLine: macdLine, + SignalLine: sig.Value, + Histogram: macdLine.Sub(sig.Value), + Ready: ready, + } + m.last = close + m.snap.Store(snap) + return snap +} + +// Load 多 goroutine 零鎖讀取最新快照 +func (m *MACD) Load() MACDSnapshot { + return m.snap.Load().(MACDSnapshot) +} diff --git a/internal/lib/strategy/macd_test.go b/internal/lib/strategy/macd_test.go new file mode 100644 index 0000000..8a0c6e6 --- /dev/null +++ b/internal/lib/strategy/macd_test.go @@ -0,0 +1,223 @@ +package strategy + +import ( + "sync" + "testing" + "time" + + "github.com/shopspring/decimal" +) + +// -------------------------- +// 參考實作(SMA-seed 的 EMA / MACD) +// -------------------------- + +type refEMA struct { + period uint + alpha decimal.Decimal + count uint + sum decimal.Decimal + ready bool + value decimal.Decimal +} + +func newRefEMA(period uint) *refEMA { + if period == 0 { + panic("period must be > 0") + } + return &refEMA{ + period: period, + alpha: decimal.NewFromInt(2). + Div(decimal.NewFromInt(int64(period)).Add(decimal.NewFromInt(1))), + } +} + +func (e *refEMA) Update(x decimal.Decimal) (val decimal.Decimal, ready bool) { + e.count++ + if !e.ready { + e.sum = e.sum.Add(x) + if e.count == e.period { + e.value = e.sum.Div(decimal.NewFromInt(int64(e.period))) // SMA seed + e.ready = true + } + return e.value, e.ready + } + e.value = e.value.Mul(decimal.NewFromInt(1).Sub(e.alpha)).Add(x.Mul(e.alpha)) + return e.value, true +} + +type refMACD struct { + fast, slow, signal *refEMA +} + +type refMACDOut struct { + MACDLine, SignalLine, Hist decimal.Decimal + Ready bool +} + +func newRefMACD(fast, slow, signal uint) *refMACD { + return &refMACD{ + fast: newRefEMA(fast), + slow: newRefEMA(slow), + signal: newRefEMA(signal), + } +} + +func (m *refMACD) Update(close decimal.Decimal) refMACDOut { + fv, fr := m.fast.Update(close) + sv, sr := m.slow.Update(close) + macd := fv.Sub(sv) + sig, sready := m.signal.Update(macd) + + ready := fr && sr && sready + return refMACDOut{ + MACDLine: macd, + SignalLine: sig, + Hist: macd.Sub(sig), + Ready: ready, + } +} + +// -------------------------- +// Tests +// -------------------------- + +func TestMACD_WarmupAndStepByStep(t *testing.T) { + // 用較短的參數讓 warm-up 與變化更明顯 + fast, slow, signal := uint(3), uint(6), uint(3) + m := NewMACD(fast, slow, signal) + ref := newRefMACD(fast, slow, signal) + + seq := []float64{10, 11, 12, 13, 12, 11, 12, 13, 14} + for i, f := range seq { + out := m.Update(d(int64(f))) + ro := ref.Update(d(int64(f))) + + // Ready 條件:三條 EMA 都完成各自 seed + if out.Ready != ro.Ready { + t.Fatalf("i=%d: ready mismatch got=%v want(ref)=%v", i, out.Ready, ro.Ready) + } + + // Ready 後數值應與參考一致(decimal 精準,可直接 Equal) + if out.Ready { + if !out.MACDLine.Equal(ro.MACDLine) || + !out.SignalLine.Equal(ro.SignalLine) || + !out.Histogram.Equal(ro.Hist) { + t.Fatalf("i=%d: values mismatch\ngot: dif=%s dea=%s hist=%s\nwant: dif=%s dea=%s hist=%s", + i, out.MACDLine, out.SignalLine, out.Histogram, + ro.MACDLine, ro.SignalLine, ro.Hist) + } + } + + // Load 與 Update 發佈的一致 + ld := m.Load() + if ld.Ready != out.Ready || + !ld.MACDLine.Equal(out.MACDLine) || + !ld.SignalLine.Equal(out.SignalLine) || + !ld.Histogram.Equal(out.Histogram) { + t.Fatalf("i=%d: Load() snapshot mismatch", i) + } + } +} + +func TestMACD_HistogramSignFlip_CrossSignal(t *testing.T) { + // 讓交叉更容易出現:短週期 + fast, slow, signal := uint(3), uint(6), uint(3) + m := NewMACD(fast, slow, signal) + + // 人工設計一段先上後下的序列,觀察 Histogram 正負翻轉 + seq := []float64{10, 11, 12, 13, 14, 13, 12, 11, 10, 9} + var prevReady bool + var prevHist decimal.Decimal + foundFlip := false + + for i, px := range seq { + out := m.Update(d(int64(px))) + if !out.Ready { + prevReady = out.Ready + prevHist = out.Histogram + continue + } + if prevReady { + // 正 -> 負 或 負 -> 正 視為切換近似(對應 MACDLine 與 SignalLine 交叉) + if (prevHist.IsPositive() && out.Histogram.IsNegative()) || + (prevHist.IsNegative() && out.Histogram.IsPositive()) { + foundFlip = true + break + } + } + prevReady = out.Ready + prevHist = out.Histogram + if i > 1000 { // 安全閥 + break + } + } + if !foundFlip { + t.Fatalf("expected at least one histogram sign flip (cross), but not found") + } +} + +func TestMACD_ParamsValidation(t *testing.T) { + // fast >= slow 應 panic + func() { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when fast >= slow") + } + }() + _ = NewMACD(12, 12, 9) + }() + + // 任一參數為 0 應 panic + func() { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when any period is 0") + } + }() + _ = NewMACD(0, 26, 9) + }() +} + +func TestMACD_MultiReadersSingleWriter(t *testing.T) { + m := NewMACD(12, 26, 9) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 多 reader:不斷 Load(),不應出現競態或 panic + reader := func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = m.Load() + } + } + } + + // 啟動多個讀者 + for i := 0; i < 8; i++ { + wg.Add(1) + go reader() + } + + // 單 writer:依序更新 + prices := []float64{10, 11, 12, 13, 12, 11, 12, 13, 14, 15, 16, 15, 14} + for _, px := range prices { + m.Update(d(int64(px))) + time.Sleep(1 * time.Millisecond) + } + + // 收攤 + close(stop) + wg.Wait() + + // 最後應該已經 ready + final := m.Load() + if !final.Ready { + t.Fatalf("final snapshot should be ready") + } +} diff --git a/internal/lib/strategy/readme.md b/internal/lib/strategy/readme.md new file mode 100644 index 0000000..abe6cac --- /dev/null +++ b/internal/lib/strategy/readme.md @@ -0,0 +1,136 @@ +# 技術指標整理:SMA、EMA、MACD + +## 1. SMA (Simple Moving Average) +**原理** +- 計算某段時間內價格的「算術平均值」。 +- 例如:20 日 SMA = 最近 20 天收盤價的平均值。 +### 公式 + * 簡單地將一段時間內的收盤價加總,再除以該期間的交易日數。 例如,5 日SMA 就是將過去 5 個交易日的收盤價加總後除以 5。 + + +**使用時機** +- 想要觀察「長期趨勢」時(例如 50 日、200 日)。 +- 作為 **支撐 / 壓力線** 參考。 + +**優點** +- 簡單易懂,市場上最常見的基準。 +- 適合長線投資者,看清楚大趨勢。 + +**缺點** +- 對價格變動反應慢(容易「滯後」)。 +- 在震盪盤中容易給出假訊號。 + +### SMA – 大趨勢濾網 +- 使用時機:想要知道市場長期偏多還是偏空。 +- 案例:台股站上 200 日 SMA → 牛市傾向。 + +--- + +## 2. EMA (Exponential Moving Average) +**原理** +- 對「近期價格」給予更高權重的移動平均。 +- 例如:20 日 EMA 會比 20 日 SMA 更快跟上價格。 + +## EMA(指數移動平均)設計的目標是: +* 越新的資料 → 權重越大。 +* 越舊的資料 → 權重遞減,但不會突然歸零。 +### 公式 + * EMA = α * close + (1 - α) * prev_EMA +### 其中: +* close = 當期價格 +* prev_EMA = 上期 EMA +* α = 平滑係數 (0~1 之間) + +### 問題在:怎麼選 α 才合理? +這裡的依據是要讓 EMA 的「有效週期」接近 N。 +* 如果用 SMA(簡單移動平均),每一筆資料在 N 期內的權重相等 = 1/N。 +* 但 EMA 要設計成:最近資料權重大,舊資料權重指數衰減。 + +透過數學推導(加權和 = 1,且平均壽命接近 N),得到: +α = 2/(n+1) + +### 👉 這樣設計的結果是: +1. EMA 的「記憶長度」大約等於 N。 +2. 和 N 日 SMA 的「平滑程度」接近,但又能更快反應新價格。 + +* **使用時機** +- 想要「更快捕捉趨勢」的交易者。 +- 常用於短中期判斷,例如 12 日、26 日 EMA。 + +**優點** +- 反應快,能更快抓到趨勢轉折。 +- 適合短線與波段交易者。 + +**缺點** +- 容易被假突破影響,訊號較「吵」。 +- 在盤整時誤導訊號比 SMA 更多。 + +### EMA – 快速抓轉折 +- 使用時機:提早嗅到行情的變化,適合短線/波段。 +- 案例:BTC 出現 12/26 EMA 黃金交叉 → 多頭信號。 +--- + +## 3. MACD (Moving Average Convergence Divergence) +**原理** +- 由兩條 EMA (快線、慢線) 的差值,再加上訊號線組成。 + - MACD 線 = 12 日 EMA − 26 日 EMA + - 訊號線 = MACD 線的 9 日 EMA +- 用來判斷「動能」與「趨勢強弱」。 + +**使用時機** +- 當價格趨勢明顯時,MACD 很有用。 +- 適合判斷「多空動能轉換」、「背離現象」。 +- 常搭配交叉訊號使用: + - MACD 上穿訊號線 → 看多 + - MACD 下穿訊號線 → 看空 + +**優點** +- 不只是趨勢,還能判斷「動能強弱」。 +- 有交叉、背離、柱狀圖多種訊號。 + +**缺點** +- 還是屬於「落後指標」,轉折不會在第一時間。 +- 在盤整行情中也會有很多假訊號。 + +### MACD – 動能 & 背離 +- 使用時機:想知道趨勢是否有力氣繼續。 +- 案例:價格創新高,但 MACD 沒創新高 → 頂背離,可能反轉。 + + +--- + +## 4. 使用上的限制 +1. **都是「落後指標」** + - 不會在第一時間告訴你轉折,只是確認趨勢。 + +2. **震盪行情容易誤導** + - 當價格在小區間來回,SMA/EMA 會不斷交叉,MACD 也會亂跳。 + +3. **參數選擇影響很大** + - 太短 → 訊號過於頻繁。太長 → 錯過行情。 + +## 5. 三者組合策略 + +1. **SMA (200 日)**:判斷長期方向。 + - 價格在 200 日 SMA 上方 → 偏多操作。 + +2. **EMA (12 日 & 26 日)**:判斷短中期趨勢。 + - 12 日 EMA 上穿 26 日 EMA → 留意進場。 + +3. **MACD**:確認動能。 + - MACD 黃金交叉,柱狀圖翻正 → 動能支持。 + +**實戰例子**: +- 2020 年 4 月,比特幣站上 200 日 SMA + EMA 黃金交叉 + MACD 翻正 → 開啟牛市行情。 + +--- + +## 6. 總結比喻 +- **SMA** = 老師傅,看大方向。 +- **EMA** = 年輕駕駛,反應快但容易緊張。 +- **MACD** = 汽車轉速表,看力道強不強。 + +組合起來就像開車上高速公路: +- 先看導航(SMA)。 +- 再看方向盤反應(EMA)。 +- 最後看轉速表(MACD)。 diff --git a/internal/lib/strategy/sma.go b/internal/lib/strategy/sma.go index bd25939..111d42a 100644 --- a/internal/lib/strategy/sma.go +++ b/internal/lib/strategy/sma.go @@ -1,46 +1,103 @@ package strategy -import "github.com/shopspring/decimal" +import ( + "sync/atomic" -/* SMA (Simple Moving Average) 簡單移動平均線。 它透過計算一段時間內股價的平均值,來判斷趨勢和提供交易信號。 + "github.com/shopspring/decimal" +) -SMA 的計算方式: - SMA 簡單地將一段時間內的收盤價加總,再除以該期間的交易日數。 例如,5 日SMA 就是將過去 5 個交易日的收盤價加總後除以 5。 -SMA 的應用: - 1. 趨勢判斷: - SMA 可以用來判斷價格的趨勢。 當股價在 SMA 上方,且 SMA 向上移動時,表示股價處於上升趨勢; - 反之,當股價在 SMA 下方,且SMA 向下移動時,表示股價處於下降趨勢。 - 2. 支撐與阻力: - SMA 也可以被視為支撐位和阻力位。 當股價下跌到SMA 附近時,SMA 可能會提供支撐;而當股價上漲到SMA 附近時,SMA 可能會提供阻力。 - 3. 交易信號: - 移動平均線的交叉也可以產生交易信號。 例如,當短期SMA 線向上穿越長期SMA 線時,被稱為黃金交叉,可能是一個買入信號; - 反之,當短期SMA 線向下穿越長期SMA 線時,被稱為死亡交叉,可能是一個賣出信號。 -SMA 的優缺點: - 優點: SMA 計算簡單、易於理解,適合新手使用。 - 缺點: SMA 對於價格變動的反應較為遲鈍,可能會落後於市場,特別是短期波動時,可能不如其他移動平均線指標準確。 -*/ +// ========================= +// SMA(固定窗寬;單寫多讀 + atomic 快照) +// ========================= +// smaCore:單 writer 的核心計算(ring buffer + 滑動平均) +type smaCore struct { + window uint + + // 狀態(單 writer) + buf []decimal.Decimal // ring buffer + head int // 下一個覆蓋位置 + size uint // 目前填充數量(<= window) + sum decimal.Decimal + ready bool +} + +func newSMACore(window uint) *smaCore { + if window == 0 { + panic("SMA window must be > 0") + } + + return &smaCore{ + window: window, + buf: make([]decimal.Decimal, window), + } +} + +// Push 只能被**單一** goroutine(writer)呼叫。 +func (s *smaCore) Push(x decimal.Decimal) (decimal.Decimal, bool) { + // 填充期 + if s.size < s.window { + s.buf[s.head] = x + s.sum = s.sum.Add(x) + s.head = (s.head + 1) % int(s.window) + s.size++ + if s.size == s.window { + s.ready = true + } + + return s.sum.Div(decimal.NewFromInt(int64(s.size))), s.ready + } + + // 滿窗:移除最舊,加入最新 + old := s.buf[s.head] + s.buf[s.head] = x + s.head = (s.head + 1) % int(s.window) + + s.sum = s.sum.Sub(old).Add(x) + avg := s.sum.Div(decimal.NewFromInt(int64(s.window))) + + return avg, true +} + +// SMASnapshot 對外發佈的不可變快照(多 reader 零鎖 Load) +type SMASnapshot struct { + Window uint + Value decimal.Decimal + Ready bool + LastInput decimal.Decimal + Size uint // 當前填充數量(<=Window) +} + +// SMA 對外使用的快照發佈器(單寫多讀) type SMA struct { - q *ringQD + core *smaCore + + last decimal.Decimal + snap atomic.Value // holds SMASnapshot } -// NewSMA 建立SMA計算器 -func NewSMA(n uint) *SMA { return &SMA{q: newRingQD(n)} } +func NewSMA(window uint) *SMA { + s := &SMA{core: newSMACore(window)} + s.snap.Store(SMASnapshot{Window: window}) + return s +} -// Push 輸入收盤價,返回當前SMA值 -func (s *SMA) Push(close decimal.Decimal) (decimal.Decimal, bool) { - s.q.push(close) - if !s.q.ready() { - return decimal.Zero, false // 尚未湊滿資料 +// Update 僅允許**單一 writer**呼叫 +func (s *SMA) Update(x decimal.Decimal) SMASnapshot { + val, ready := s.core.Push(x) + s.last = x + ss := SMASnapshot{ + Window: s.core.window, + Value: val, + Ready: ready, + LastInput: x, + Size: s.core.size, } - return s.q.mean(), true + s.snap.Store(ss) + return ss } -// GetSMA 取得目前 SMA 值 -func (s *SMA) GetSMA() (decimal.Decimal, bool) { - if !s.q.ready() { - return decimal.Zero, false // 尚未湊滿資料 - } - - return s.q.mean(), true +// Load 多 reader 零鎖讀最新快照 +func (s *SMA) Load() SMASnapshot { + return s.snap.Load().(SMASnapshot) } diff --git a/internal/lib/strategy/sma_test.go b/internal/lib/strategy/sma_test.go index c144b05..d2216c5 100644 --- a/internal/lib/strategy/sma_test.go +++ b/internal/lib/strategy/sma_test.go @@ -1,132 +1,120 @@ package strategy import ( - "github.com/shopspring/decimal" + "sync" "testing" + "time" + + "github.com/shopspring/decimal" ) -// --- SMA 的表格式驅動測試 --- +func d(i int64) decimal.Decimal { return decimal.NewFromInt(i) } -func TestSMA(t *testing.T) { - d10 := decimal.NewFromInt(10) - d20 := decimal.NewFromInt(20) - d30 := decimal.NewFromInt(30) - d40 := decimal.NewFromInt(40) - d50 := decimal.NewFromInt(50) - - // 定義 Push 過程中的檢查點結構 - type pushCheck struct { - wantSMA decimal.Decimal - wantOK bool +func TestSMA_WarmupAndSliding(t *testing.T) { + type step struct { + in int64 + wantVal string // 用字串比對可避免浮點誤差(decimal 本就精準) + ready bool } - - testCases := []struct { - name string - n uint - inputs []decimal.Decimal - pushChecks []pushCheck // 驗證每一次 Push 的回傳值 - wantFinalSMA decimal.Decimal // 驗證最後 GetSMA 的回傳值 - wantFinalOK bool + tests := []struct { + name string + window uint + steps []step }{ { - name: "SMA-5 未滿載", - n: 5, - inputs: []decimal.Decimal{d10, d20, d30}, - pushChecks: []pushCheck{ - {decimal.Zero, false}, - {decimal.Zero, false}, - {decimal.Zero, false}, + name: "warmup_then_ready_and_slide", + window: 3, + steps: []step{ + {in: 1, wantVal: "1", ready: false}, // [1] avg=1 + {in: 2, wantVal: "1.5", ready: false}, // [1,2] avg=1.5 + {in: 3, wantVal: "2", ready: true}, // [1,2,3] avg=2 + {in: 4, wantVal: "3", ready: true}, // [2,3,4] avg=3 + {in: 5, wantVal: "4", ready: true}, // [3,4,5] avg=4 + {in: 6, wantVal: "5", ready: true}, // [4,5,6] avg=5 + {in: 7, wantVal: "6", ready: true}, // [5,6,7] avg=6 }, - wantFinalSMA: decimal.Zero, - wantFinalOK: false, }, { - name: "SMA-3 剛好滿載", - n: 3, - inputs: []decimal.Decimal{d10, d20, d30}, - pushChecks: []pushCheck{ - {decimal.Zero, false}, - {decimal.Zero, false}, - {decimal.NewFromInt(20), true}, // (10+20+30)/3 + name: "window_1_behaves_as_latest_value", + window: 1, + steps: []step{ + {in: 10, wantVal: "10", ready: true}, + {in: 11, wantVal: "11", ready: true}, + {in: 12, wantVal: "12", ready: true}, }, - wantFinalSMA: decimal.NewFromInt(20), - wantFinalOK: true, - }, - { - name: "SMA-3 滾動計算", - n: 3, - inputs: []decimal.Decimal{d10, d20, d30, d40, d50}, - pushChecks: []pushCheck{ - {decimal.Zero, false}, - {decimal.Zero, false}, - {decimal.NewFromInt(20), true}, // (10+20+30)/3 - {decimal.NewFromInt(30), true}, // (20+30+40)/3 - {decimal.NewFromInt(40), true}, // (30+40+50)/3 - }, - wantFinalSMA: decimal.NewFromInt(40), - wantFinalOK: true, - }, - { - name: "SMA-1 邊界情況", - n: 1, - inputs: []decimal.Decimal{d10, d20, d30}, - pushChecks: []pushCheck{ - {d10, true}, - {d20, true}, - {d30, true}, - }, - wantFinalSMA: d30, - wantFinalOK: true, - }, - { - name: "SMA-0 無效情況", - n: 0, - inputs: []decimal.Decimal{d10, d20, d30}, - pushChecks: []pushCheck{ - {decimal.Zero, false}, - {decimal.Zero, false}, - {decimal.Zero, false}, - }, - wantFinalSMA: decimal.Zero, - wantFinalOK: false, - }, - { - name: "在空實例上呼叫 GetSMA", - n: 5, - inputs: []decimal.Decimal{}, - pushChecks: []pushCheck{}, - wantFinalSMA: decimal.Zero, - wantFinalOK: false, }, } - for _, tc := range testCases { + for _, tc := range tests { + tc := tc t.Run(tc.name, func(t *testing.T) { - sma := NewSMA(tc.n) - - // 驗證每一次 Push 的結果 - for i, input := range tc.inputs { - gotSMA, gotOK := sma.Push(input) - // 確保 pushChecks 陣列不會索引越界 - if i < len(tc.pushChecks) { - check := tc.pushChecks[i] - if gotOK != check.wantOK { - t.Errorf("Push #%d 的 OK 狀態錯誤: got %v, want %v", i+1, gotOK, check.wantOK) - } - if !gotSMA.Equals(check.wantSMA) { - t.Errorf("Push #%d 的 SMA 值錯誤: got %s, want %s", i+1, gotSMA.String(), check.wantSMA.String()) - } + s := NewSMA(tc.window) + for i, st := range tc.steps { + got := s.Update(d(st.in)) + if got.Value.String() != st.wantVal { + t.Fatalf("step %d: got value %s, want %s", i, got.Value, st.wantVal) + } + if got.Ready != st.ready { + t.Fatalf("step %d: ready mismatch, got %v, want %v", i, got.Ready, st.ready) + } + // Load() 應該等於最新快照 + ld := s.Load() + if ld.Value.String() != st.wantVal || ld.Ready != st.ready { + t.Fatalf("step %d: Load() not latest snapshot", i) } - } - - // 在所有 Push 操作完成後,驗證最終 GetSMA 的結果 - finalSMA, finalOK := sma.GetSMA() - if finalOK != tc.wantFinalOK { - t.Errorf("最終 GetSMA 的 OK 狀態錯誤: got %v, want %v", finalOK, tc.wantFinalOK) - } - if !finalSMA.Equals(tc.wantFinalSMA) { - t.Errorf("最終 GetSMA 的 SMA 值錯誤: got %s, want %s", finalSMA.String(), tc.wantFinalSMA.String()) } }) } } + +func TestSMA_NewZeroWindowShouldPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when window=0") + } + }() + _ = NewSMA(0) +} + +func TestSMA_MultiReadersSingleWriter(t *testing.T) { + s := NewSMA(3) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 多 reader 併發讀取(零鎖) + reader := func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = s.Load() // 不做判斷,重點是不得 panic + } + } + } + + // 啟動多個讀者 + for i := 0; i < 8; i++ { + wg.Add(1) + go reader() + } + + // 單 writer 更新 + prices := []int64{1, 2, 3, 4, 5, 6, 7} + for _, p := range prices { + s.Update(d(p)) + time.Sleep(1 * time.Millisecond) + } + + // 停 reader + close(stop) + wg.Wait() + + // 最終應為 [5,6,7] 的平均=6,且 ready=true + got := s.Load() + if got.Value.String() != "6" || !got.Ready { + t.Fatalf("final snapshot mismatch: got value=%s ready=%v", got.Value, got.Ready) + } +}