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
2 changes: 1 addition & 1 deletion strategy/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The `strategy` package defines the core interfaces and logic for generating buy,
## Key Components

- **Models:** `Action` (Buy/Sell/Hold). `Result` exists only to compare expected vs. actual actions in tests. `Outcome`/`OutcomeWithContext` are functions, not a model — they simulate the P&L of a given action sequence.
- **Metrics:** `SharpeRatioWithContext` computes a risk-adjusted metric from an outcome curve (more may follow, e.g. Sortino, max drawdown).
- **Metrics:** `SharpeRatioWithContext` and `SortinoRatioWithContext` compute risk-adjusted metrics from an outcome curve (more may follow, e.g. max drawdown).
- **Interface:** `Strategy` (not generic).
- **Combinators:** `AndStrategy`, `OrStrategy`, `MajorityStrategy`, `SplitStrategy`.
- **Predefined:** `BuyAndHoldStrategy`.
Expand Down
43 changes: 43 additions & 0 deletions strategy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ The information provided on this project is strictly for informational purposes
- [func OutcomeWithContext\[T helper.Number\]\(ctx context.Context, values \<\-chan T, actions \<\-chan Action\) \<\-chan float64](<#OutcomeWithContext>)
- [func SharpeRatio\(outcomes \<\-chan float64, periodsPerYear int\) float64](<#SharpeRatio>)
- [func SharpeRatioWithContext\(ctx context.Context, outcomes \<\-chan float64, periodsPerYear int\) float64](<#SharpeRatioWithContext>)
- [func SortinoRatio\(outcomes \<\-chan float64, periodsPerYear int\) float64](<#SortinoRatio>)
- [func SortinoRatioWithContext\(ctx context.Context, outcomes \<\-chan float64, periodsPerYear int\) float64](<#SortinoRatioWithContext>)
- [type Action](<#Action>)
- [func \(a Action\) Annotation\(\) string](<#Action.Annotation>)
- [type AndStrategy](<#AndStrategy>)
Expand Down Expand Up @@ -105,6 +107,17 @@ const (
)
```

<a name="DefaultSortinoRatioPeriodsPerYear"></a>

```go
const (
// DefaultSortinoRatioPeriodsPerYear is the default number of return periods in a year, matching
// the approximate number of trading days used to annualize a Sortino Ratio computed from daily
// outcomes.
DefaultSortinoRatioPeriodsPerYear = 252
)
```

<a name="ActionSources"></a>
## func [ActionSources](<https://github.com/cinar/indicator/blob/master/strategy/strategy.go#L152>)

Expand Down Expand Up @@ -308,6 +321,36 @@ The risk\-free rate is assumed to be zero. The outcomes channel is assumed to ho

Fewer than two outcome values, or a return series with zero \(or floating\-point\-noise\-level\) variance, such as a strategy that never trades, yields a Sharpe Ratio of zero rather than dividing by a near\-zero standard deviation.

<a name="SortinoRatio"></a>
## func [SortinoRatio](<https://github.com/cinar/indicator/blob/master/strategy/sortino_ratio.go#L67>)

```go
func SortinoRatio(outcomes <-chan float64, periodsPerYear int) float64
```

SortinoRatio wraps SortinoRatioWithContext for backwards compatibility.

Deprecated: Use SortinoRatioWithContext instead.

<a name="SortinoRatioWithContext"></a>
## func [SortinoRatioWithContext](<https://github.com/cinar/indicator/blob/master/strategy/sortino_ratio.go#L50>)

```go
func SortinoRatioWithContext(ctx context.Context, outcomes <-chan float64, periodsPerYear int) float64
```

SortinoRatioWithContext computes the annualized Sortino Ratio for the given stream of cumulative outcome values, as produced by OutcomeWithContext, supporting context cancellation.

```
Sortino = Mean(periodReturns) / DownsideDeviation(periodReturns) * Sqrt(periodsPerYear)
```

Unlike the Sharpe Ratio, which divides by the standard deviation of all returns, the Sortino Ratio divides by the downside deviation: the root\-mean\-square of only the shortfall below a minimum acceptable return \(assumed to be zero here\), with periods at or above that return contributing zero. Two return series with identical upside volatility but different downside volatility therefore yield different Sortino Ratios, even when their Sharpe Ratios are equal.

The outcomes channel is assumed to hold one cumulative return value per trading period \(for example, one per daily snapshot\), which is exactly what OutcomeWithContext produces. Per\-period returns are derived from the change in the underlying equity curve \(1 \+ outcome\) between consecutive outcomes.

Fewer than two outcome values, or a return series with zero \(or floating\-point\-noise\-level\) downside deviation, such as a strategy whose returns never fall below the minimum acceptable return, yields a Sortino Ratio of zero rather than dividing by a near\-zero downside deviation.

<a name="Action"></a>
## type [Action](<https://github.com/cinar/indicator/blob/master/strategy/action.go#L15>)

Expand Down
88 changes: 88 additions & 0 deletions strategy/sortino_ratio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2021-2026 The Indicator Authors.
// The source code is provided under GNU AGPLv3 License.
// https://github.com/cinar/indicator

package strategy

import (
"context"
"math"

"github.com/cinar/indicator/v2/helper"
)

const (
// DefaultSortinoRatioPeriodsPerYear is the default number of return periods in a year, matching
// the approximate number of trading days used to annualize a Sortino Ratio computed from daily
// outcomes.
DefaultSortinoRatioPeriodsPerYear = 252

// sortinoRatioMinDownsideDeviation is the smallest downside deviation treated as nonzero. A
// near-zero downside deviation is dominated by floating-point rounding noise rather than actual
// downside risk, and dividing by it would blow up into an arbitrarily large, meaningless Sortino
// Ratio.
sortinoRatioMinDownsideDeviation = 1e-9

// sortinoRatioMinimumAcceptableReturn is the per-period return below which a return counts as
// downside risk.
sortinoRatioMinimumAcceptableReturn = 0
)

// SortinoRatioWithContext computes the annualized Sortino Ratio for the given stream of cumulative
// outcome values, as produced by OutcomeWithContext, supporting context cancellation.
//
// Sortino = Mean(periodReturns) / DownsideDeviation(periodReturns) * Sqrt(periodsPerYear)
//
// Unlike the Sharpe Ratio, which divides by the standard deviation of all returns, the Sortino
// Ratio divides by the downside deviation: the root-mean-square of only the shortfall below a
// minimum acceptable return (assumed to be zero here), with periods at or above that return
// contributing zero. Two return series with identical upside volatility but different downside
// volatility therefore yield different Sortino Ratios, even when their Sharpe Ratios are equal.
//
// The outcomes channel is assumed to hold one cumulative return value per trading period (for
// example, one per daily snapshot), which is exactly what OutcomeWithContext produces. Per-period
// returns are derived from the change in the underlying equity curve (1 + outcome) between
// consecutive outcomes.
//
// Fewer than two outcome values, or a return series with zero (or floating-point-noise-level)
// downside deviation, such as a strategy whose returns never fall below the minimum acceptable
// return, yields a Sortino Ratio of zero rather than dividing by a near-zero downside deviation.
func SortinoRatioWithContext(ctx context.Context, outcomes <-chan float64, periodsPerYear int) float64 {
equity := helper.IncrementByWithContext(ctx, outcomes, 1.0)
returns := helper.ChanToSlice(helper.ChangeRatioWithContext(ctx, equity, 1))

meanReturn := helper.Mean(returns)
downsideDeviationReturn := downsideDeviation(returns, sortinoRatioMinimumAcceptableReturn)

if downsideDeviationReturn < sortinoRatioMinDownsideDeviation {
return 0
}

return (meanReturn / downsideDeviationReturn) * math.Sqrt(float64(periodsPerYear))
}

// SortinoRatio wraps SortinoRatioWithContext for backwards compatibility.
//
// Deprecated: Use SortinoRatioWithContext instead.
func SortinoRatio(outcomes <-chan float64, periodsPerYear int) float64 {
return SortinoRatioWithContext(context.Background(), outcomes, periodsPerYear)
}

// downsideDeviation returns the population downside deviation of the given returns relative to
// the given minimum acceptable return (MAR): the root-mean-square of the shortfall below MAR,
// treating a return at or above MAR as zero shortfall. Returns zero for an empty slice.
func downsideDeviation(returns []float64, mar float64) float64 {
if len(returns) == 0 {
return 0
}

var sumSquaredShortfall float64

for _, r := range returns {
if shortfall := mar - r; shortfall > 0 {
sumSquaredShortfall += shortfall * shortfall
}
}

return math.Sqrt(sumSquaredShortfall / float64(len(returns)))
}
97 changes: 97 additions & 0 deletions strategy/sortino_ratio_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2021-2026 The Indicator Authors.
// The source code is provided under GNU AGPLv3 License.
// https://github.com/cinar/indicator

package strategy_test

import (
"context"
"math"
"testing"
"time"

"github.com/cinar/indicator/v2/helper"
"github.com/cinar/indicator/v2/strategy"
)

func TestSortinoRatioWithContext(t *testing.T) {
// Equity curve 1, 1.5, 2.25, 3.375, 1.6875 corresponds to period returns of exactly
// 0.5, 0.5, 0.5, -0.5 (each a clean power-of-two fraction, so no floating-point rounding
// noise), giving a mean of 0.25. Only the last return falls below the zero minimum
// acceptable return, giving a downside deviation of sqrt(0.5^2/4) = 0.25.
outcomes := helper.SliceToChan([]float64{0, 0.5, 1.25, 2.375, 0.6875})

actual := strategy.SortinoRatioWithContext(context.Background(), outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)
expected := 1.0 * math.Sqrt(float64(strategy.DefaultSortinoRatioPeriodsPerYear))

if math.Abs(actual-expected) > 1e-9 {
t.Fatalf("actual %v expected %v", actual, expected)
}
}

func TestSortinoRatioWithContextNoDownside(t *testing.T) {
// Equity doubling every period (1, 2, 4, 8) gives an exactly representable return series of
// 1.0, 1.0, 1.0, none of which fall below the zero minimum acceptable return, so the downside
// deviation is zero.
outcomes := helper.SliceToChan([]float64{0, 1, 3, 7})

actual := strategy.SortinoRatioWithContext(context.Background(), outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)

if actual != 0 {
t.Fatalf("actual %v expected 0", actual)
}
}

func TestSortinoRatioWithContextEmpty(t *testing.T) {
outcomes := helper.SliceToChan([]float64{})

actual := strategy.SortinoRatioWithContext(context.Background(), outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)

if actual != 0 {
t.Fatalf("actual %v expected 0", actual)
}
}

func TestSortinoRatioWithContextSingleOutcome(t *testing.T) {
outcomes := helper.SliceToChan([]float64{0})

actual := strategy.SortinoRatioWithContext(context.Background(), outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)

if actual != 0 {
t.Fatalf("actual %v expected 0", actual)
}
}

func TestSortinoRatioWithContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
outcomes := make(chan float64)

cancel()

done := make(chan float64)

go func() {
done <- strategy.SortinoRatioWithContext(ctx, outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)
}()

select {
case actual := <-done:
if actual != 0 {
t.Fatalf("actual %v expected 0", actual)
}

case <-time.After(2 * time.Second):
t.Fatal("timeout - SortinoRatioWithContext did not return after cancellation")
}
}

func TestSortinoRatio(t *testing.T) {
outcomes := helper.SliceToChan([]float64{0, 0.5, 1.25, 2.375, 0.6875})

actual := strategy.SortinoRatio(outcomes, strategy.DefaultSortinoRatioPeriodsPerYear)
expected := 1.0 * math.Sqrt(float64(strategy.DefaultSortinoRatioPeriodsPerYear))

if math.Abs(actual-expected) > 1e-9 {
t.Fatalf("actual %v expected %v", actual, expected)
}
}
Loading