Skip to content

Fix percentChange formula and Sharpe ratio calculation#82

Open
sahilsk11 wants to merge 1 commit intomasterfrom
fix/metrics-calculation-bugs
Open

Fix percentChange formula and Sharpe ratio calculation#82
sahilsk11 wants to merge 1 commit intomasterfrom
fix/metrics-calculation-bugs

Conversation

@sahilsk11
Copy link
Copy Markdown
Owner

@sahilsk11 sahilsk11 commented Apr 6, 2026

Summary

Two bugs in the financial metrics calculations:

1. percentChange divides by wrong value (both packages)

The formula was:

return ((end - start) / end) * 100

This divides by the end price, but percent change should be relative to the start price:

return ((end - start) / start) * 100

Example: A stock going from $100 → $110 should be +10%, but the old formula computed (110-100)/110 = 9.09%. This affected factor score calculations (via pricePercentChange) and the stdev cache (daily return calculations in price.service.go).

2. Sharpe ratio uses non-annualized stdev

The Sharpe ratio was computed as:

sharpeRatio := annualizedReturn / stdev  // daily stdev

Since the numerator is annualized, the denominator should be too:

sharpeRatio := annualizedReturn / annualizedStdev

The annualizedStdev was already being calculated (stdev * sqrt(252)) and returned in the result struct — it just wasn't being used for Sharpe.

Tests added

  • Unit tests for percentChange in both internal/calculator and internal/data packages

Summary by CodeRabbit

  • Bug Fixes

    • Corrected percentage change calculation to use starting value as the reference point instead of ending value, affecting all price change metrics
    • Fixed Sharpe ratio calculation to properly annualize both return and volatility components
  • Tests

    • Added test suites for percentage change calculations with multiple scenarios

- percentChange was dividing by end price instead of start price,
  producing incorrect percent change values. Fixed in both
  internal/calculator and internal/data packages.

- Sharpe ratio was using daily (non-annualized) stdev in the
  denominator while the numerator used annualized return. Now
  consistently uses annualizedStdev.

- Added unit tests for percentChange in both packages.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

Two percentChange functions across calculator and data packages are corrected to use starting value as denominator instead of ending value, aligning with standard financial practice. Additionally, Sharpe ratio calculation updated to use annualized standard deviation. Tests added to validate the corrected formulas.

Changes

Cohort / File(s) Summary
Percent Change Formula Corrections
internal/calculator/factor_expression.service.go, internal/data/price.service.go
Updated percentChange() formula denominator from end to start value: ((end - start) / start) * 100. Affects downstream metrics depending on percent-change calculations.
Sharpe Ratio Calculation Update
internal/calculator/metrics.service.go
Modified Sharpe ratio computation to divide annualizedReturn by annualizedStdev instead of non-annualized stdev value.
Percent Change Test Coverage
internal/calculator/percent_change_test.go, internal/data/percent_change_test.go
New test files with table-driven test cases validating percentChange() across scenarios: increase, decrease, no change, and doubling. Uses 0.0001 tolerance for assertions.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


A rabbit hops through formulas bright, 🐰
Percent changes now done right,
Starting values lead the way,
Sharpe ratios find their play,
Tests ensure the math takes flight! 📊✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: fixing the percentChange formula (changing divisor from end to start value) and correcting the Sharpe ratio calculation (using annualized standard deviation instead of daily).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/metrics-calculation-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/calculator/factor_expression.service.go (1)

668-684: ⚠️ Potential issue | 🟠 Major

Fail fast on zero start price in PricePercentChange to avoid non-finite factor scores.

percentChange (Line 683) divides by start without validation. In this package, you already return errors from PricePercentChange, so use that path for startPrice == 0.

💡 Proposed fix
 func (h factorMetricsHandler) PricePercentChange(pr *data.PriceCache, symbol string, start, end time.Time) (float64, error) {
 	startPrice, err := pr.Get(symbol, start)
 	if err != nil {
 		return 0, err
 	}
+	if startPrice == 0 {
+		return 0, factorMetricsMissingDataError{
+			fmt.Errorf("pricePercentChange start price is zero for %s on %s", symbol, start.Format(time.DateOnly)),
+		}
+	}
 
 	endPrice, err := pr.Get(symbol, end)
 	if err != nil {
 		return 0, err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/calculator/factor_expression.service.go` around lines 668 - 684, The
percentChange function currently divides by start without checking for zero;
modify PricePercentChange (not percentChange) to detect startPrice == 0 after
retrieving it from pr.Get and return a descriptive error (e.g., "start price is
zero") instead of calling percentChange, so callers fail fast and avoid
non-finite factor scores; leave percentChange unchanged or document it expects
non-zero start.
🧹 Nitpick comments (2)
internal/calculator/percent_change_test.go (1)

8-55: Add a start == 0 boundary test case.

Given the new denominator logic, include one test that asserts behavior when the start value is zero (e.g., finite fallback or expected non-finite handling).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/calculator/percent_change_test.go` around lines 8 - 55, Add a
boundary test case for start == 0 in Test_percentChange: append a test struct
(e.g., name "start zero", end 100, start 0) to the tests slice and in the t.Run
assertion check the actual behavior of percentChange(end, start) for non-finite
results using math.IsInf/got or math.IsNaN(got); if the implementation instead
uses a finite fallback, assert equality to that fallback value—target the
percentChange function in percent_change_test.go.
internal/data/percent_change_test.go (1)

8-49: Mirror the zero-denominator boundary test in this package as well.

Please add a start == 0 case here too, so both percentChange implementations are protected against regression on the same edge condition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/data/percent_change_test.go` around lines 8 - 49, Add a test case
covering the zero-denominator boundary for percentChange: include a test where
start == 0 (e.g., end: 100, start: 0) and assert the function doesn't panic and
returns the same special value the other package expects (check for +Inf with
math.IsInf(got, 1) or the matching behavior used elsewhere); update the
Test_percentChange table and the t.Run assertion to handle comparing special
values (use math.IsInf/math.IsNaN when expected is a non-finite value) so the
test mirrors the zero-denominator protection in the other package.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/calculator/metrics.service.go`:
- Line 57: Guard the Sharpe division by checking annualizedStdev before
computing sharpeRatio: replace the direct division sharpeRatio :=
annualizedReturn / annualizedStdev with a safe branch that treats zero (or
near-zero) volatility as a special case (e.g. if math.Abs(annualizedStdev) <
1e-12 then set sharpeRatio = 0.0) else compute sharpeRatio = annualizedReturn /
annualizedStdev; use math.Abs and a small epsilon to avoid floating-point edge
cases and update any callers/serializers to expect the chosen fallback value.

In `@internal/data/price.service.go`:
- Around line 99-101: The percentChange function divides by start
unconditionally; add a guard in percentChange to handle start == 0 (e.g., return
0.0 or another defined sentinel) before performing ((end - start) / start) * 100
to avoid producing non-finite values that break downstream stdev cache logic;
update percentChange to check start == 0 and return the chosen safe value, and
ensure callers that expect non-finite values still behave correctly.

---

Outside diff comments:
In `@internal/calculator/factor_expression.service.go`:
- Around line 668-684: The percentChange function currently divides by start
without checking for zero; modify PricePercentChange (not percentChange) to
detect startPrice == 0 after retrieving it from pr.Get and return a descriptive
error (e.g., "start price is zero") instead of calling percentChange, so callers
fail fast and avoid non-finite factor scores; leave percentChange unchanged or
document it expects non-zero start.

---

Nitpick comments:
In `@internal/calculator/percent_change_test.go`:
- Around line 8-55: Add a boundary test case for start == 0 in
Test_percentChange: append a test struct (e.g., name "start zero", end 100,
start 0) to the tests slice and in the t.Run assertion check the actual behavior
of percentChange(end, start) for non-finite results using math.IsInf/got or
math.IsNaN(got); if the implementation instead uses a finite fallback, assert
equality to that fallback value—target the percentChange function in
percent_change_test.go.

In `@internal/data/percent_change_test.go`:
- Around line 8-49: Add a test case covering the zero-denominator boundary for
percentChange: include a test where start == 0 (e.g., end: 100, start: 0) and
assert the function doesn't panic and returns the same special value the other
package expects (check for +Inf with math.IsInf(got, 1) or the matching behavior
used elsewhere); update the Test_percentChange table and the t.Run assertion to
handle comparing special values (use math.IsInf/math.IsNaN when expected is a
non-finite value) so the test mirrors the zero-denominator protection in the
other package.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9f96ab39-5560-47fc-ae47-b20318d97551

📥 Commits

Reviewing files that changed from the base of the PR and between 8a5ce02 and 27f7383.

📒 Files selected for processing (5)
  • internal/calculator/factor_expression.service.go
  • internal/calculator/metrics.service.go
  • internal/calculator/percent_change_test.go
  • internal/data/percent_change_test.go
  • internal/data/price.service.go

annualizedReturn := math.Pow((endValue/startValue), 1/numYears) - 1

sharpeRatio := annualizedReturn / stdev
sharpeRatio := annualizedReturn / annualizedStdev
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard zero-volatility cases before Sharpe division.

Line 57 can emit Inf/NaN when annualizedStdev == 0, and this value flows to persistence/API consumers.

💡 Proposed fix
-	sharpeRatio := annualizedReturn / annualizedStdev
+	sharpeRatio := 0.0
+	if annualizedStdev != 0 {
+		sharpeRatio = annualizedReturn / annualizedStdev
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sharpeRatio := annualizedReturn / annualizedStdev
sharpeRatio := 0.0
if annualizedStdev != 0 {
sharpeRatio = annualizedReturn / annualizedStdev
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/calculator/metrics.service.go` at line 57, Guard the Sharpe division
by checking annualizedStdev before computing sharpeRatio: replace the direct
division sharpeRatio := annualizedReturn / annualizedStdev with a safe branch
that treats zero (or near-zero) volatility as a special case (e.g. if
math.Abs(annualizedStdev) < 1e-12 then set sharpeRatio = 0.0) else compute
sharpeRatio = annualizedReturn / annualizedStdev; use math.Abs and a small
epsilon to avoid floating-point edge cases and update any callers/serializers to
expect the chosen fallback value.

Comment on lines 99 to 101
func percentChange(end, start float64) float64 {
return ((end - start) / end) * 100
return ((end - start) / start) * 100
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a zero-denominator guard in percentChange before computing returns.

Line 100 divides by start unconditionally. If start == 0, this yields a non-finite result and can corrupt the stdev cache computation path.

💡 Proposed fix
 func percentChange(end, start float64) float64 {
+	if start == 0 {
+		return math.NaN()
+	}
 	return ((end - start) / start) * 100
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func percentChange(end, start float64) float64 {
return ((end - start) / end) * 100
return ((end - start) / start) * 100
}
func percentChange(end, start float64) float64 {
if start == 0 {
return math.NaN()
}
return ((end - start) / start) * 100
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/data/price.service.go` around lines 99 - 101, The percentChange
function divides by start unconditionally; add a guard in percentChange to
handle start == 0 (e.g., return 0.0 or another defined sentinel) before
performing ((end - start) / start) * 100 to avoid producing non-finite values
that break downstream stdev cache logic; update percentChange to check start ==
0 and return the chosen safe value, and ensure callers that expect non-finite
values still behave correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant