diff --git a/.squad/agents/dinesh/charter.md b/.squad/agents/dinesh/charter.md new file mode 100644 index 00000000..159686e8 --- /dev/null +++ b/.squad/agents/dinesh/charter.md @@ -0,0 +1,17 @@ +# Dinesh — Tester + +## Role +Tester and quality assurance. Writes tests, validates edge cases, ensures correctness. + +## Boundaries +- Owns all test files and test scenarios +- Uses unittest.TestCase (NOT pytest style) +- pytest is only used as the test runner (`poetry run pytest`) +- Does NOT implement production code — Richard handles that +- Reviews test coverage and identifies missing edge cases + +## Project Context +- **Project:** investing-algorithm-framework (Python) +- **Stack:** Python, backtesting, portfolio management, algorithmic trading +- **User:** marcvanduyn +- **Test framework:** unittest (not pytest). Tests use unittest.TestCase classes. diff --git a/.squad/agents/dinesh/history.md b/.squad/agents/dinesh/history.md new file mode 100644 index 00000000..9f62100f --- /dev/null +++ b/.squad/agents/dinesh/history.md @@ -0,0 +1,15 @@ +# Dinesh — History + +## Learnings +- Project initialized: investing-algorithm-framework, Python algorithmic trading framework +- User: marcvanduyn +- Test convention: unittest.TestCase, pytest as runner only +- Drawdown metrics live in `investing_algorithm_framework/services/metrics/drawdown.py` +- Tests at `tests/services/metrics/test_drawdowns.py` — 18 tests across 4 test classes +- `get_equity_curve()` does NOT sort snapshots by timestamp — relies on input order +- `get_max_daily_drawdown()` uses pandas resample('1D').last() + pct_change() for daily returns +- `get_max_drawdown_duration()` tracks drawdown via (timestamp - drawdown_start).days +- Edge case bug found: `get_max_daily_drawdown` returns abs(min(daily_returns)) even when all returns are positive — needs clamping to negative returns only. Filed in decisions inbox for Richard. +- Mock snapshots need `created_at` (datetime) and `total_value` (float) attributes +- Positive-only returns edge case was fixed by Coordinator via `min(daily_returns.min(), 0)` clamping — all 18 tests now pass. +- Richard confirmed: `get_equity_curve` sorts by `created_at`, `get_max_daily_drawdown` uses `pct_change()`, `get_max_drawdown_duration` uses `.days` on timestamp diffs. diff --git a/.squad/agents/gilfoyle/charter.md b/.squad/agents/gilfoyle/charter.md new file mode 100644 index 00000000..4b2faf98 --- /dev/null +++ b/.squad/agents/gilfoyle/charter.md @@ -0,0 +1,16 @@ +# Gilfoyle — Lead + +## Role +Lead engineer. Scope decisions, architecture review, code review, quality gates. + +## Boundaries +- Owns architecture decisions and code review +- Does NOT implement features directly — delegates to Richard +- Does NOT write tests — delegates to Dinesh +- Reviews all significant changes before merge + +## Project Context +- **Project:** investing-algorithm-framework (Python) +- **Stack:** Python, backtesting, portfolio management, algorithmic trading +- **User:** marcvanduyn +- **Test framework:** unittest (not pytest). Tests use unittest.TestCase classes. diff --git a/.squad/agents/gilfoyle/history.md b/.squad/agents/gilfoyle/history.md new file mode 100644 index 00000000..28bbe402 --- /dev/null +++ b/.squad/agents/gilfoyle/history.md @@ -0,0 +1,5 @@ +# Gilfoyle — History + +## Learnings +- Project initialized: investing-algorithm-framework, Python algorithmic trading framework +- User: marcvanduyn diff --git a/.squad/agents/richard/charter.md b/.squad/agents/richard/charter.md new file mode 100644 index 00000000..b689b8af --- /dev/null +++ b/.squad/agents/richard/charter.md @@ -0,0 +1,16 @@ +# Richard — Backend Dev + +## Role +Backend developer. Python implementation, algorithms, data processing, backtesting logic, metrics computation. + +## Boundaries +- Owns all Python implementation: services, domain logic, infrastructure +- Works on backtesting, portfolio management, metrics, data providers +- Does NOT review own code — Gilfoyle reviews +- Does NOT write test files — Dinesh handles tests + +## Project Context +- **Project:** investing-algorithm-framework (Python) +- **Stack:** Python, backtesting, portfolio management, algorithmic trading +- **User:** marcvanduyn +- **Test framework:** unittest (not pytest). Tests use unittest.TestCase classes. diff --git a/.squad/agents/richard/history.md b/.squad/agents/richard/history.md new file mode 100644 index 00000000..089ba77e --- /dev/null +++ b/.squad/agents/richard/history.md @@ -0,0 +1,13 @@ +# Richard — History + +## Learnings +- Project initialized: investing-algorithm-framework, Python algorithmic trading framework +- User: marcvanduyn +- **Drawdown metrics (2026-03-29):** Fixed 3 bugs in `services/metrics/drawdown.py` and `services/metrics/equity_curve.py`. + - `get_equity_curve` must sort by `created_at` — all drawdown functions depend on it for peak-tracking. + - `get_max_daily_drawdown` should use `pct_change()` for worst single-day decline, not peak-to-trough. + - `get_max_drawdown_duration` must diff actual timestamps (`.days`), not count snapshot entries. + - Existing tests only cover `get_drawdown_series`, `get_max_drawdown`, `get_max_drawdown_absolute` — the three fixed functions need tests added by Dinesh. + - Test runner: `python3 -m pytest` (not `python`). + - Edge case: `get_max_daily_drawdown` must clamp to negative returns only — `min(daily_returns.min(), 0)`. Found by Dinesh during testing. + - Dinesh wrote 18 tests covering all fixed functions at `tests/services/metrics/test_drawdowns.py`. diff --git a/.squad/agents/scribe/charter.md b/.squad/agents/scribe/charter.md new file mode 100644 index 00000000..c1fd79fa --- /dev/null +++ b/.squad/agents/scribe/charter.md @@ -0,0 +1,16 @@ +# Scribe + +## Role +Silent session logger. Maintains decisions.md, cross-agent context, orchestration logs. + +## Boundaries +- Writes orchestration logs, session logs, decision merges +- Never speaks to the user +- Commits .squad/ state via git + +## Tasks +1. Merge decisions inbox → decisions.md +2. Write orchestration log entries +3. Write session log entries +4. Cross-pollinate learnings to agent history files +5. Git commit .squad/ state diff --git a/.squad/agents/scribe/history.md b/.squad/agents/scribe/history.md new file mode 100644 index 00000000..6443e785 --- /dev/null +++ b/.squad/agents/scribe/history.md @@ -0,0 +1,5 @@ +# Scribe — History + +## Learnings +- Project initialized: investing-algorithm-framework +- User: marcvanduyn diff --git a/.squad/casting/history.json b/.squad/casting/history.json new file mode 100644 index 00000000..5d558f17 --- /dev/null +++ b/.squad/casting/history.json @@ -0,0 +1,10 @@ +{ + "assignments": [ + { + "assignment_id": "init-2026-03-29", + "universe": "silicon-valley", + "timestamp": "2026-03-29T00:00:00Z", + "agents": ["Gilfoyle", "Richard", "Dinesh"] + } + ] +} diff --git a/.squad/casting/policy.json b/.squad/casting/policy.json new file mode 100644 index 00000000..a25dec03 --- /dev/null +++ b/.squad/casting/policy.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "universe_allowlist": ["silicon-valley"], + "max_capacity": 10, + "overflow_strategy": "diegetic-expansion" +} diff --git a/.squad/casting/registry.json b/.squad/casting/registry.json new file mode 100644 index 00000000..52966316 --- /dev/null +++ b/.squad/casting/registry.json @@ -0,0 +1,26 @@ +[ + { + "persistent_name": "Gilfoyle", + "role": "Lead", + "universe": "silicon-valley", + "created_at": "2026-03-29T00:00:00Z", + "legacy_named": false, + "status": "active" + }, + { + "persistent_name": "Richard", + "role": "Backend Dev", + "universe": "silicon-valley", + "created_at": "2026-03-29T00:00:00Z", + "legacy_named": false, + "status": "active" + }, + { + "persistent_name": "Dinesh", + "role": "Tester", + "universe": "silicon-valley", + "created_at": "2026-03-29T00:00:00Z", + "legacy_named": false, + "status": "active" + } +] diff --git a/.squad/ceremonies.md b/.squad/ceremonies.md new file mode 100644 index 00000000..fe832777 --- /dev/null +++ b/.squad/ceremonies.md @@ -0,0 +1,16 @@ +# Ceremonies + +## Design Review + +- **type:** manual +- **facilitator:** Gilfoyle +- **participants:** [Richard, Dinesh] +- **trigger:** User requests "design review" or "architecture review" + +## Code Review + +- **type:** auto +- **when:** after +- **condition:** implementation complete +- **facilitator:** Gilfoyle +- **participants:** [Dinesh] diff --git a/.squad/decisions.md b/.squad/decisions.md new file mode 100644 index 00000000..2d2e84ce --- /dev/null +++ b/.squad/decisions.md @@ -0,0 +1,16 @@ +# Decisions + +_Append-only ledger of team decisions._ + +### 2026-03-29T00:00:00Z: Fix drawdown metric bugs (#407) +**By:** Richard (Backend Dev) +**What:** Three bugs fixed in drawdown metrics: +1. `get_equity_curve` now sorts snapshots by `created_at` — all downstream metrics depend on chronological order. +2. `get_max_daily_drawdown` rewritten to compute worst single-day percentage decline (via `pct_change`) instead of peak-to-trough on daily data. This makes it distinct from `get_max_drawdown`. +3. `get_max_drawdown_duration` now computes elapsed calendar days between timestamps instead of counting snapshot entries. +**Why:** Metrics were inconsistent with portfolio snapshot data. `max_daily_drawdown == max_drawdown` was a symptom of both doing peak-to-trough. Duration was wrong for non-daily snapshot frequencies. + +### 2026-03-29T00:00:00Z: Drawdown edge case — positive-only returns +**By:** Dinesh (Tester), fixed by Coordinator +**What:** `get_max_daily_drawdown` must clamp to negative returns only (`min(daily_returns.min(), 0)`). When all daily returns are positive, the function was returning the smallest positive return as a "drawdown." +**Why:** Bug found during test coverage work for issue #407. diff --git a/.squad/routing.md b/.squad/routing.md new file mode 100644 index 00000000..838ada21 --- /dev/null +++ b/.squad/routing.md @@ -0,0 +1,17 @@ +# Routing Rules + +## Default Routes + +| Pattern | Agent | Reason | +|---------|-------|--------| +| Architecture, design decisions, code review | Gilfoyle | Lead handles scope and quality | +| Python implementation, algorithms, data processing, backtesting, metrics | Richard | Backend handles core framework code | +| Tests, test cases, quality assurance, edge cases | Dinesh | Tester handles all test work | +| Logging, decisions, session tracking | Scribe | Scribe handles memory and docs | +| Work queue, backlog monitoring | Ralph | Ralph monitors work pipeline | + +## Domain Keywords + +- **Gilfoyle:** architecture, review, scope, decision, design, API design, refactor planning +- **Richard:** python, algorithm, backtest, portfolio, metrics, drawdown, orders, positions, data provider, services, infrastructure +- **Dinesh:** test, unittest, coverage, edge case, regression, validation, assertion diff --git a/.squad/team.md b/.squad/team.md new file mode 100644 index 00000000..3598f661 --- /dev/null +++ b/.squad/team.md @@ -0,0 +1,18 @@ +# Squad Team + +## Project Context + +- **Project:** investing-algorithm-framework +- **Stack:** Python, backtesting, portfolio management, algorithmic trading +- **Description:** Open-source framework for creating and deploying investing algorithms with backtesting, portfolio management, and multi-exchange support +- **User:** marcvanduyn + +## Members + +| Name | Role | Scope | Badge | +|------|------|-------|-------| +| Gilfoyle | Lead | Scope, decisions, code review | 🏗️ Lead | +| Richard | Backend Dev | Python, algorithms, data processing | 🔧 Backend | +| Dinesh | Tester | Tests, quality, edge cases | 🧪 Tester | +| Scribe | Session Logger | Memory, decisions, session logs | 📋 Scribe | +| Ralph | Work Monitor | Work queue, backlog, keep-alive | 🔄 Monitor | diff --git a/investing_algorithm_framework/services/metrics/drawdown.py b/investing_algorithm_framework/services/metrics/drawdown.py index b5356e2d..1ae173c9 100644 --- a/investing_algorithm_framework/services/metrics/drawdown.py +++ b/investing_algorithm_framework/services/metrics/drawdown.py @@ -109,16 +109,17 @@ def get_max_drawdown(snapshots: List[PortfolioSnapshot]) -> float: def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float: """ - Calculate the maximum daily drawdown of the portfolio as a percentage from the peak. + Calculate the worst single-day decline of the portfolio as a percentage. - This is the largest drop in equity (in percentage) from a peak to a trough - during the backtest period, calculated on a daily basis. + This is the largest day-over-day percentage drop in equity, + NOT the peak-to-trough drawdown (use get_max_drawdown for that). Args: snapshots (List[PortfolioSnapshot]): List of portfolio snapshots Returns: - float: The maximum daily drawdown as a negative percentage (e.g., -5.0 for a 5% drawdown). + float: The maximum single-day drawdown as a positive percentage + (e.g., 0.05 for a 5% single-day decline). """ # Create DataFrame from snapshots data = [(s.created_at, s.total_value) for s in snapshots] @@ -136,36 +137,31 @@ def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float: # Filter out non-positive values positive_values = daily_df[daily_df['total_value'] > 0]['total_value'] - if positive_values.empty: + if positive_values.empty or len(positive_values) < 2: return 0.0 - peak = positive_values.iloc[0] - max_daily_drawdown_pct = 0.0 + # Compute day-over-day returns; the worst single-day decline + # is the most negative return (ignore positive returns) + daily_returns = positive_values.pct_change().dropna() + negative_returns = daily_returns[daily_returns < 0] - for equity in positive_values: - if equity > peak: - peak = equity - - # Avoid division by zero (shouldn't happen but extra safety) - if peak <= 0: - continue - - drawdown_pct = (equity - peak) / peak - max_daily_drawdown_pct = min(max_daily_drawdown_pct, drawdown_pct) + if negative_returns.empty: + return 0.0 - return abs(max_daily_drawdown_pct) # Return as positive percentage + return abs(negative_returns.min()) def get_max_drawdown_duration(snapshots: List[PortfolioSnapshot]) -> int: """ Calculate the maximum duration of drawdown in days. - This is the longest period where the portfolio equity was below its peak. + This is the longest period (in calendar days) where the portfolio + equity was below its peak. Args: snapshots (List[PortfolioSnapshot]): List of portfolio snapshots Returns: - int: The maximum drawdown duration in days. + int: The maximum drawdown duration in calendar days. """ equity_curve = get_equity_curve(snapshots) if not equity_curve: @@ -173,17 +169,26 @@ def get_max_drawdown_duration(snapshots: List[PortfolioSnapshot]) -> int: peak = equity_curve[0][0] max_duration = 0 - current_duration = 0 + drawdown_start = None - for equity, _ in equity_curve: + for equity, timestamp in equity_curve: if equity < peak: - current_duration += 1 + # Entering or continuing a drawdown + if drawdown_start is None: + drawdown_start = timestamp else: - max_duration = max(max_duration, current_duration) - current_duration = 0 - peak = equity # Reset peak to current equity + # Recovered to or above the peak + if drawdown_start is not None: + elapsed = (timestamp - drawdown_start).days + max_duration = max(max_duration, elapsed) + drawdown_start = None + peak = equity - max_duration = max(max_duration, current_duration) # Final check + # If still in drawdown at the end of the series + if drawdown_start is not None and len(equity_curve) > 0: + last_timestamp = equity_curve[-1][1] + elapsed = (last_timestamp - drawdown_start).days + max_duration = max(max_duration, elapsed) return max_duration diff --git a/investing_algorithm_framework/services/metrics/equity_curve.py b/investing_algorithm_framework/services/metrics/equity_curve.py index bb4a7d29..af8536f8 100644 --- a/investing_algorithm_framework/services/metrics/equity_curve.py +++ b/investing_algorithm_framework/services/metrics/equity_curve.py @@ -21,4 +21,7 @@ def get_equity_curve( total_size = snapshot.total_value series.append((total_size, timestamp)) + # Sort by timestamp to ensure chronological order + series.sort(key=lambda x: x[1]) + return series diff --git a/tests/services/metrics/test_drawdowns.py b/tests/services/metrics/test_drawdowns.py index 102707f9..1c87fdbb 100644 --- a/tests/services/metrics/test_drawdowns.py +++ b/tests/services/metrics/test_drawdowns.py @@ -1,8 +1,21 @@ import unittest +import random from datetime import datetime, timedelta from unittest.mock import MagicMock from investing_algorithm_framework import get_drawdown_series, \ - get_max_drawdown, get_max_drawdown_absolute + get_max_drawdown, get_max_drawdown_absolute, get_max_daily_drawdown, \ + get_max_drawdown_duration + + +def _make_snapshots(timestamps, values): + """Helper to create mock PortfolioSnapshot objects.""" + snapshots = [] + for ts, val in zip(timestamps, values): + snapshot = MagicMock() + snapshot.created_at = ts + snapshot.total_value = val + snapshots.append(snapshot) + return snapshots class TestDrawdownFunctions(unittest.TestCase): @@ -54,3 +67,323 @@ def test_max_drawdown(self): def test_max_drawdown_absolute(self): max_drawdown = get_max_drawdown_absolute(self.backtest_result.portfolio_snapshots) self.assertEqual(max_drawdown, 300) # 1200 - 900 = 300 + + +class TestGetMaxDailyDrawdown(unittest.TestCase): + """Tests for get_max_daily_drawdown — worst single-day decline.""" + + def test_worst_daily_decline_differs_from_max_drawdown(self): + """ + Max daily decline should be the worst single-day return, + not the peak-to-trough drawdown. + + Equity: [1000, 950, 900, 1100, 1300] + Daily returns: -5%, -5.26%, +22.2%, +18.2% + Worst daily: -5.26% (950→900) + Peak-to-trough: -10% (1000→900) + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + ] + values = [1000, 950, 900, 1100, 1300] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_daily_drawdown(snapshots) + # Worst single-day: (900 - 950) / 950 ≈ -0.05263 + expected = abs((900 - 950) / 950) + self.assertAlmostEqual(result, expected, places=4) + + def test_larger_daily_drop_in_middle(self): + """ + When the largest single-day drop is not the overall trough. + + Equity: [1000, 1200, 1100, 900, 1300] + Daily returns: +20%, -8.33%, -18.18%, +44.4% + Worst daily: -18.18% (1100→900) + Peak-to-trough: -25% (1200→900) + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + ] + values = [1000, 1200, 1100, 900, 1300] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_daily_drawdown(snapshots) + expected = abs((900 - 1100) / 1100) # ≈ 0.1818 + self.assertAlmostEqual(result, expected, places=4) + + def test_all_positive_returns(self): + """ + When all daily returns are positive, there is no decline, + so max daily drawdown should be 0. + + The function should only consider negative day-over-day returns. + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + ] + values = [1000, 1100, 1200, 1400] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_daily_drawdown(snapshots) + # No negative returns exist → worst daily drawdown = 0 + self.assertAlmostEqual(result, 0.0, places=6, + msg="All-positive returns should yield 0 drawdown") + + def test_single_snapshot(self): + """Single snapshot means no daily return — drawdown should be 0.""" + snapshots = _make_snapshots( + [datetime(2024, 1, 1)], [1000] + ) + result = get_max_daily_drawdown(snapshots) + self.assertEqual(result, 0.0) + + def test_resamples_intraday_to_daily(self): + """ + Multiple intra-day snapshots should be resampled to daily + (last value of the day). + + Raw: Day1 9am=1000, Day1 3pm=1200, Day2 9am=1100, Day3 9am=900 + Daily (last): [1200, 1100, 900] + Daily returns: -8.33%, -18.18% + Worst daily: -18.18% (NOT peak-to-trough of -25%) + """ + timestamps = [ + datetime(2024, 1, 1, 9, 0), + datetime(2024, 1, 1, 15, 0), + datetime(2024, 1, 2, 9, 0), + datetime(2024, 1, 3, 9, 0), + ] + values = [1000, 1200, 1100, 900] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_daily_drawdown(snapshots) + # After resample: [1200, 1100, 900] + # Worst day-over-day return: (900 - 1100) / 1100 = -0.1818 + expected = abs((900 - 1100) / 1100) + self.assertAlmostEqual(result, expected, places=4) + + def test_flat_equity(self): + """Constant equity means no daily changes — drawdown = 0.""" + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ] + values = [1000, 1000, 1000] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_daily_drawdown(snapshots) + self.assertEqual(result, 0.0) + + +class TestGetMaxDrawdownDuration(unittest.TestCase): + """Tests for get_max_drawdown_duration — drawdown duration in actual days.""" + + def test_daily_snapshots_duration(self): + """ + With daily snapshots, drawdown duration should be in calendar days. + + Equity: [1000, 1200, 900, 1000, 1100, 1100, 1300] + Dates: Jan1 Jan2 Jan3 Jan4 Jan5 Jan6 Jan7 + Peak at Jan 2 (1200). Below peak: Jan 3-6. Recovery: Jan 7. + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + datetime(2024, 1, 6), + datetime(2024, 1, 7), + ] + values = [1000, 1200, 900, 1000, 1100, 1100, 1300] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_drawdown_duration(snapshots) + # With daily data, 4 snapshots below peak = 4 calendar days + self.assertGreaterEqual(result, 4) + + def test_weekly_snapshots_returns_days_not_snapshot_count(self): + """ + With weekly snapshots, should return calendar days, NOT snapshot count. + + Equity: [1000, 1200, 900, 1100, 1300] + Dates: Jan1 Jan8 Jan15 Jan22 Jan29 (weekly) + Peak at Jan 8 (1200). Below peak: Jan 15, Jan 22. Recovery: Jan 29. + Buggy code returns 2 (snapshot count). + Fixed code should return days: ≥7 (e.g. 14 or 21 depending on measurement). + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 8), + datetime(2024, 1, 15), + datetime(2024, 1, 22), + datetime(2024, 1, 29), + ] + values = [1000, 1200, 900, 1100, 1300] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_drawdown_duration(snapshots) + self.assertGreater( + result, 2, + "Duration should be in calendar days, not snapshot count" + ) + + def test_no_drawdown(self): + """Monotonically increasing equity has no drawdown — duration = 0.""" + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ] + values = [1000, 1100, 1200] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_drawdown_duration(snapshots) + self.assertEqual(result, 0) + + def test_drawdown_extends_to_end_of_series(self): + """ + If the portfolio never recovers, the drawdown extends to the last + snapshot and should be measured in calendar days. + + Equity: [1000, 800, 700, 900] + Dates: Jan1 Jan8 Jan15 Jan22 (weekly) + Peak at Jan 1. Never recovered. + Buggy: returns 3 (snapshot count). + Fixed: should return ≥14 (calendar days from Jan 1 to Jan 22). + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 8), + datetime(2024, 1, 15), + datetime(2024, 1, 22), + ] + values = [1000, 800, 700, 900] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_drawdown_duration(snapshots) + self.assertGreater( + result, 3, + "Duration should be in calendar days, not snapshot count" + ) + + def test_multiple_drawdown_periods_returns_longest(self): + """ + When there are multiple drawdown periods, return the longest one + in calendar days. + + Equity: [1000, 900, 1100, 1050, 1000, 900, 1200] + Dates: Jan1 Jan2 Jan3 Jan10 Jan17 Jan24 Jan31 + Drawdown 1: Jan 1→Jan 2 (1 day, 1 snapshot below peak) + Drawdown 2: Jan 3 peak (1100), below: Jan 10, Jan 17, Jan 24 + Recovery: Jan 31. Duration = 21+ days in calendar time. + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 10), + datetime(2024, 1, 17), + datetime(2024, 1, 24), + datetime(2024, 1, 31), + ] + values = [1000, 900, 1100, 1050, 1000, 900, 1200] + snapshots = _make_snapshots(timestamps, values) + + result = get_max_drawdown_duration(snapshots) + # Second drawdown period is the longest: 3 snapshots below peak + # but 21+ calendar days. Must be greater than snapshot count. + self.assertGreater( + result, 3, + "Duration should be in calendar days, not snapshot count" + ) + + def test_empty_snapshots(self): + """Empty snapshot list should return 0.""" + result = get_max_drawdown_duration([]) + self.assertEqual(result, 0) + + +class TestDrawdownConsistency(unittest.TestCase): + """Verify equity curve sort order and metric consistency.""" + + def test_unsorted_snapshots_produce_same_drawdown_as_sorted(self): + """ + Shuffled snapshots should produce the same max_drawdown + as chronologically sorted snapshots (functions should sort + internally or the equity curve should be timestamp-ordered). + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + ] + values = [1000, 1200, 900, 1100, 1300] + + sorted_snapshots = _make_snapshots(timestamps, values) + + pairs = list(zip(timestamps, values)) + random.seed(42) + random.shuffle(pairs) + shuffled_ts, shuffled_vals = zip(*pairs) + shuffled_snapshots = _make_snapshots(shuffled_ts, shuffled_vals) + + sorted_result = get_max_drawdown(sorted_snapshots) + shuffled_result = get_max_drawdown(shuffled_snapshots) + + self.assertAlmostEqual(sorted_result, shuffled_result, places=6) + + def test_max_drawdown_matches_manual_computation_from_total_value(self): + """ + Max drawdown from the equity curve should match a manual + computation from the snapshot total_value fields. + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + ] + values = [1000, 1200, 900, 1100, 1300] + snapshots = _make_snapshots(timestamps, values) + + # Manual: peak = 1200, trough = 900 + # Max drawdown = (1200 - 900) / 1200 = 0.25 + expected = 0.25 + result = get_max_drawdown(snapshots) + self.assertAlmostEqual(result, expected, places=6) + + def test_drawdown_series_timestamps_match_snapshots(self): + """ + The drawdown series timestamps should correspond to the + snapshot timestamps. + """ + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ] + values = [1000, 900, 1100] + snapshots = _make_snapshots(timestamps, values) + + drawdown_series = get_drawdown_series(snapshots) + self.assertEqual(len(drawdown_series), len(timestamps)) + + for (_, ts), expected_ts in zip(drawdown_series, timestamps): + self.assertEqual(ts, expected_ts)