Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions internal/engine/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ import (
// exchangeNew creates exchange adapters; overridden in tests.
var exchangeNew = exchange.New

// enrichPulse spawns async OHLCV enrichment for a pulse event; a var so tests
// can count spawns (duplicate events must not enrich twice).
var enrichPulse = func(p *Pipeline, evt types.AnomalyEvent) {
p.enrichOpportunityAsync(evt)
}

// ────────────────────────────────────────────────────────────────
// Pipeline
// ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1052,11 +1058,15 @@ func (p *Pipeline) eventAggregator(
}

// One OpportunitySession per pulse event; async OHLCV enrichment may attach tickets.
// HandlePulseEvent returns (nil, nil) for duplicate events — only enrich
// when a session was actually created, or the same session would get a
// second watch goroutine.
if isMarketPulseEvent(evt.EventType) && p.opportunity != nil {
if _, err := p.opportunity.HandlePulseEvent(evt); err != nil {
sess, err := p.opportunity.HandlePulseEvent(evt)
if err != nil {
p.log.Warn("opportunity session failed", "error", err, "event", evt.EventType)
} else {
p.enrichOpportunityAsync(evt)
} else if sess != nil {
enrichPulse(p, evt)
}
}

Expand Down
41 changes: 41 additions & 0 deletions internal/engine/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,44 @@ func (m *mockPipelineExchange) FetchOHLCV(context.Context, string, string, int,
return nil, nil
}
func (m *mockPipelineExchange) Close() error { return nil }

func TestEventAggregator_DuplicatePulseEnrichesOnce(t *testing.T) {
cfg := &types.Config{
Opportunity: types.OpportunityConfig{Enabled: true},
Storage: types.StorageConfig{DatabasePath: filepath.Join(t.TempDir(), "kairos.db")},
}
p := NewPipeline(cfg, nil)
if p.opportunity == nil {
t.Fatal("opportunity service must be wired when enabled")
}
calls := 0
old := enrichPulse
enrichPulse = func(_ *Pipeline, _ types.AnomalyEvent) { calls++ }
defer func() { enrichPulse = old }()

delivery := make(chan types.AnomalyEvent, 2)
src := make(chan types.AnomalyEvent, 2)
evt := types.AnomalyEvent{
EventType: "market_impulse",
EventID: "e-dup",
Timestamp: float64(time.Now().Unix()),
Data: map[string]any{"direction": "up", "state_to": "IMPULSE_UP", "leaders": []string{"SOL/USDT:USDT"}},
}
src <- evt
src <- evt // same EventID again — session already exists
close(src)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.eventAggregator(ctx, []eventSource{{ch: src, origin: "okx"}}, delivery)
for i := 0; i < 2; i++ {
select {
case <-delivery:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for delivery")
}
}
if calls != 1 {
t.Fatalf("duplicate pulse must enrich exactly once, got %d", calls)
}
}
Loading