@jesdi/skills 0.0.50 → 0.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/test-prune/SKILL.md +156 -0
- package/skills/test-prune/prune.py +202 -0
- package/skills/test-prune/prune_repo.py +319 -0
- package/skills/test-prune/prune_review.py +363 -0
- package/skills/test-prune/prune_runs.py +240 -0
- package/skills/test-prune/prune_source.py +237 -0
- package/skills/test-prune/prune_strengthen.py +301 -0
- package/skills/test-prune/tests/conftest.py +15 -0
- package/skills/test-prune/tests/prune_harness.py +205 -0
- package/skills/test-prune/tests/test_prune_guards.py +871 -0
- package/skills/test-prune/tests/test_prune_inputs.py +97 -0
- package/skills/test-prune/tests/test_prune_review.py +113 -0
- package/skills/test-prune/tests/test_prune_source.py +84 -0
- package/skills/test-prune/tests/test_prune_strengthen.py +183 -0
- package/skills/test-prune/tests/test_prune_unattended.py +225 -0
- package/skills/test-prune/tests/testdata/gh +16 -0
- package/skills/test-prune/tests/testdata/repo/Makefile +25 -0
- package/skills/test-prune/tests/testdata/repo/backend/mutation_baseline.json +16 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/concentration.py +2 -0
- package/skills/test-prune/tests/testdata/repo/backend/services/rebalancer.py +11 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/conftest.py +6 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_concentration.py +73 -0
- package/skills/test-prune/tests/testdata/repo/backend/tests/test_rebalancer.py +77 -0
- package/skills/test-prune/tests/testdata/repo/fake_engine.py +289 -0
- package/skills/test-prune/tests/testdata/repo/kill_map.json +131 -0
- package/skills-manifest.json +7 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Unattended prune: branch, PR, cap, bisect, nothing-to-do exits and deletion shape (ticket 21)."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from prune_harness import PRUNE_BRANCH, REFERENCE, R, arg, cand, case, lines_with
|
|
5
|
+
|
|
6
|
+
CULPRIT = R + "test_sell_only_rebalance" # sole killer of x_rebalance__mutmut_20..22
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def deleted_on(target, ref):
|
|
10
|
+
return target.ids_at(target.base) - target.ids_at(ref)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def assert_base_untouched(target):
|
|
14
|
+
assert target.git("rev-parse", "main").strip() == target.base
|
|
15
|
+
assert target.remote_sha("main") == target.base
|
|
16
|
+
assert target.git("symbolic-ref", "--short", "HEAD").strip() == "main"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_delete_only(target, ref):
|
|
20
|
+
numstat = target.git("diff", "--numstat", target.base, ref).splitlines()
|
|
21
|
+
assert numstat, "the branch changes nothing"
|
|
22
|
+
for line in numstat:
|
|
23
|
+
added, _, path = line.split("\t")
|
|
24
|
+
assert added == "0", line
|
|
25
|
+
assert path.startswith("backend/tests/") and not path.endswith("conftest.py"), line
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def assert_no_pr(target):
|
|
29
|
+
assert target.remote_branches() == {"main"}
|
|
30
|
+
assert target.git("branch", "--list", "chore/*").strip() == ""
|
|
31
|
+
assert target.log("gh") == []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_unattended_six_deletions_open_one_pr_and_leave_the_base_branch_at_c1(target):
|
|
35
|
+
ids = [case(i) for i in range(1, 7)]
|
|
36
|
+
reasons = {i: f"reviewer: {i.split('::')[1]} only repeats the reference equal-weight check" for i in ids}
|
|
37
|
+
target.prepare([cand(i) for i in ids], accept=ids, reasons=reasons, deletions={i: REFERENCE for i in ids})
|
|
38
|
+
|
|
39
|
+
target.ok("prune")
|
|
40
|
+
|
|
41
|
+
assert_base_untouched(target)
|
|
42
|
+
assert PRUNE_BRANCH in target.remote_branches()
|
|
43
|
+
head = target.remote_sha(PRUNE_BRANCH)
|
|
44
|
+
assert deleted_on(target, head) == set(ids)
|
|
45
|
+
assert target.ids_at(head) <= target.ids_at(target.base)
|
|
46
|
+
assert_delete_only(target, head)
|
|
47
|
+
|
|
48
|
+
(create,) = target.pr_creates()
|
|
49
|
+
assert arg(create["argv"], "--head") == PRUNE_BRANCH
|
|
50
|
+
assert arg(create["argv"], "--base") == "main"
|
|
51
|
+
body = create["body"]
|
|
52
|
+
for test_id in ids:
|
|
53
|
+
assert REFERENCE in lines_with(body, test_id), f"{test_id} listed without its containing test"
|
|
54
|
+
assert reasons[test_id] in body
|
|
55
|
+
assert "services/rebalancer.py: 20 survivors, baseline 20" in body # ratchet result per module
|
|
56
|
+
assert "services/concentration.py: 2 survivors, baseline 2" in body
|
|
57
|
+
assert "Prune gate PASSED" in body
|
|
58
|
+
assert not [c for c in target.log("gh") if c["argv"][:2] == ["pr", "merge"]]
|
|
59
|
+
assert [g["base"] for g in target.log("prune-gate")] == [target.base]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_unattended_fourteen_accepted_deletes_ten_and_leaves_four_for_next_week(target):
|
|
63
|
+
ids = [case(i) for i in range(1, 15)]
|
|
64
|
+
target.prepare([cand(i) for i in ids], accept=ids, deletions={i: REFERENCE for i in ids})
|
|
65
|
+
|
|
66
|
+
target.ok("prune")
|
|
67
|
+
|
|
68
|
+
head = target.remote_sha(PRUNE_BRANCH)
|
|
69
|
+
assert deleted_on(target, head) == set(ids[:10])
|
|
70
|
+
assert set(ids[10:]) <= target.ids_at(head)
|
|
71
|
+
(create,) = target.pr_creates()
|
|
72
|
+
body = create["body"]
|
|
73
|
+
assert "left for next week" in body.lower()
|
|
74
|
+
for test_id in ids[10:]:
|
|
75
|
+
assert test_id in body
|
|
76
|
+
assert_base_untouched(target)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_bisect_restores_the_sole_killer_and_reports_it_with_23_against_20(target):
|
|
80
|
+
ids = [case(i) for i in range(1, 10)] + [CULPRIT]
|
|
81
|
+
deletions = {i: REFERENCE for i in ids[:9]}
|
|
82
|
+
deletions[CULPRIT] = R + "test_hold_when_on_target"
|
|
83
|
+
target.prepare([cand(i, into) for i, into in deletions.items()], accept=ids, deletions=deletions)
|
|
84
|
+
|
|
85
|
+
out = target.ok("prune").stdout
|
|
86
|
+
|
|
87
|
+
head = target.remote_sha(PRUNE_BRANCH)
|
|
88
|
+
assert deleted_on(target, head) == set(ids[:9])
|
|
89
|
+
assert CULPRIT in target.ids_at(head)
|
|
90
|
+
(create,) = target.pr_creates()
|
|
91
|
+
for text in (out, create["body"]):
|
|
92
|
+
report = lines_with(text, CULPRIT)
|
|
93
|
+
assert re.search(r"\b23\b", report) and re.search(r"\b20\b", report), report
|
|
94
|
+
assert "services/rebalancer.py" in text
|
|
95
|
+
assert_base_untouched(target)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unattended_no_protected_candidates_pushes_nothing_and_exits_0(target, tmp_path):
|
|
99
|
+
summary = tmp_path / "summary.md"
|
|
100
|
+
env = {"GITHUB_STEP_SUMMARY": str(summary)}
|
|
101
|
+
target.prepare([cand(case(1), unprotected=True)], env=env)
|
|
102
|
+
|
|
103
|
+
proc = target.run("prune", env=env)
|
|
104
|
+
|
|
105
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
106
|
+
assert "no protected candidates" in proc.stdout
|
|
107
|
+
assert "no protected candidates" in summary.read_text()
|
|
108
|
+
assert [e["args"] for e in target.log("test-candidates")] == [["--protected-only"]]
|
|
109
|
+
assert_no_pr(target)
|
|
110
|
+
assert_base_untouched(target)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_unattended_every_deletion_restored_pushes_nothing_and_exits_0(target, tmp_path):
|
|
114
|
+
summary = tmp_path / "summary.md"
|
|
115
|
+
env = {"GITHUB_STEP_SUMMARY": str(summary)}
|
|
116
|
+
target.prepare([cand(CULPRIT, R + "test_hold_when_on_target")], accept=[CULPRIT],
|
|
117
|
+
deletions={CULPRIT: R + "test_hold_when_on_target"}, env=env)
|
|
118
|
+
|
|
119
|
+
proc = target.run("prune", env=env)
|
|
120
|
+
|
|
121
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
122
|
+
report = lines_with(proc.stdout, CULPRIT)
|
|
123
|
+
assert re.search(r"\b23\b", report) and re.search(r"\b20\b", report), proc.stdout
|
|
124
|
+
assert "services/rebalancer.py" in proc.stdout
|
|
125
|
+
assert CULPRIT in summary.read_text()
|
|
126
|
+
assert_no_pr(target)
|
|
127
|
+
assert_base_untouched(target)
|
|
128
|
+
assert target.git("status", "--porcelain") == ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_unattended_failed_gate_pushes_nothing_and_exits_1(target):
|
|
132
|
+
target.prepare([cand(case(1))], accept=[case(1)], deletions={case(1): REFERENCE})
|
|
133
|
+
|
|
134
|
+
proc = target.run("prune", env={"FAKE_GATE_FAIL": "1"})
|
|
135
|
+
|
|
136
|
+
assert proc.returncode == 1, proc.stdout + proc.stderr
|
|
137
|
+
assert "Prune gate FAILED at point 1" in proc.stdout
|
|
138
|
+
assert target.remote_branches() == {"main"} # a local branch may stay; nothing leaves the machine
|
|
139
|
+
assert target.log("gh") == []
|
|
140
|
+
assert_base_untouched(target)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
SHAPED = '''"""Rebalancer tests."""
|
|
144
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
145
|
+
|
|
146
|
+
import pytest
|
|
147
|
+
|
|
148
|
+
from services.rebalancer import apply_drift, compute_trades, rebalance
|
|
149
|
+
|
|
150
|
+
TOLERANCE = 1e-9
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _weights(**kw):
|
|
154
|
+
return dict(kw)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.fixture
|
|
158
|
+
def targets():
|
|
159
|
+
return _weights(AAA=0.6, BBB=0.4)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_zero_weight(targets):
|
|
163
|
+
trades = compute_trades({"AAA": 0.0}, targets)
|
|
164
|
+
assert trades["AAA"] == pytest.approx(0.6)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_small_weight(targets):
|
|
168
|
+
trades = compute_trades({"AAA": 0.01}, targets)
|
|
169
|
+
assert trades["AAA"] > 0
|
|
170
|
+
assert set(trades) == {"AAA", "BBB"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_hold_when_within_band(targets):
|
|
174
|
+
assert rebalance({"AAA": 0.6000001, "BBB": 0.3999999}, targets) == {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_sell_only_rebalance(targets):
|
|
178
|
+
trades = rebalance({"AAA": 0.9, "BBB": 0.1}, targets)
|
|
179
|
+
assert trades["AAA"] < 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_trade_size_is_decimal():
|
|
183
|
+
assert isinstance(Decimal("1.5"), Decimal)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.parametrize("drift", [0.1, 0.2, 0.3], ids=["low", "mid", "high"])
|
|
187
|
+
def test_apply_drift_bounds(drift):
|
|
188
|
+
assert 0 <= apply_drift(drift) <= 1
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestDriftHelpers:
|
|
192
|
+
@pytest.fixture
|
|
193
|
+
def base(self):
|
|
194
|
+
return 1.0
|
|
195
|
+
'''
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_deletion_shape_removes_whole_tests_classes_and_dead_imports_only(target):
|
|
199
|
+
applied = {
|
|
200
|
+
R + "TestDriftHelpers::test_drift_scales": R + "test_apply_drift_bounds[high]", # class keeps its fixture
|
|
201
|
+
R + "TestRounding::test_round_down": R + "test_trade_size_is_decimal", # with its decorator
|
|
202
|
+
R + "TestRounding::test_round_half_up": R + "test_trade_size_is_decimal", # class left with a docstring
|
|
203
|
+
R + "test_compute_trades_sums_to_zero": R + "test_zero_weight", # with its comment; `math` dies with it
|
|
204
|
+
R + "test_hold_when_on_target": R + "test_small_weight",
|
|
205
|
+
}
|
|
206
|
+
skipped = {
|
|
207
|
+
R + "test_apply_drift_bounds[low]": R + "test_apply_drift_bounds[mid]", # siblings not accepted
|
|
208
|
+
R + "test_hold_when_within_band": R + "test_hold_when_on_target", # its `into` is deleted earlier
|
|
209
|
+
}
|
|
210
|
+
deletions = {**applied, **skipped}
|
|
211
|
+
target.prepare([cand(i, into) for i, into in deletions.items()], accept=list(deletions), deletions=deletions)
|
|
212
|
+
|
|
213
|
+
batch = target.read_state("batch.json")
|
|
214
|
+
assert sorted(d["test_id"] for d in batch["deletions"]) == sorted(applied)
|
|
215
|
+
assert sorted(s["test_id"] for s in batch["skipped"]) == sorted(skipped)
|
|
216
|
+
assert all(s["reason"].strip() for s in batch["skipped"])
|
|
217
|
+
|
|
218
|
+
target.ok("prune")
|
|
219
|
+
|
|
220
|
+
head = target.remote_sha(PRUNE_BRANCH)
|
|
221
|
+
assert target.show(head, "backend/tests/test_rebalancer.py") == SHAPED
|
|
222
|
+
assert deleted_on(target, head) == set(applied)
|
|
223
|
+
assert_delete_only(target, head)
|
|
224
|
+
assert target.git("diff", "--name-only", target.base, head).split() == ["backend/tests/test_rebalancer.py"]
|
|
225
|
+
assert_base_untouched(target)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!@PYTHON@
|
|
2
|
+
"""Fake gh: logs argv and the --body-file content, prints a PR URL for `pr create`."""
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
args = sys.argv[1:]
|
|
7
|
+
body = None
|
|
8
|
+
if "--body-file" in args:
|
|
9
|
+
path = args[args.index("--body-file") + 1]
|
|
10
|
+
body = sys.stdin.read() if path == "-" else open(path).read()
|
|
11
|
+
elif "--body" in args:
|
|
12
|
+
body = args[args.index("--body") + 1]
|
|
13
|
+
with open("@LOGS@/gh.log", "a") as f:
|
|
14
|
+
f.write(json.dumps({"argv": args, "body": body}) + "\n")
|
|
15
|
+
if args[:2] == ["pr", "create"]:
|
|
16
|
+
print("https://github.com/acme/portfolio/pull/42")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Fixture target repo: every target calls the fake engine (see fake_engine.py).
|
|
2
|
+
ENGINE = @PYTHON@ $(CURDIR)/fake_engine.py --logs @LOGS@ --root $(CURDIR)
|
|
3
|
+
|
|
4
|
+
.PHONY: mutation mutation-check mutation-baseline test-candidates prune-gate test crap
|
|
5
|
+
|
|
6
|
+
mutation:
|
|
7
|
+
$(ENGINE) mutation
|
|
8
|
+
|
|
9
|
+
mutation-check:
|
|
10
|
+
$(ENGINE) mutation-check
|
|
11
|
+
|
|
12
|
+
mutation-baseline:
|
|
13
|
+
$(ENGINE) mutation-baseline
|
|
14
|
+
|
|
15
|
+
test-candidates:
|
|
16
|
+
$(ENGINE) test-candidates $(ARGS)
|
|
17
|
+
|
|
18
|
+
prune-gate:
|
|
19
|
+
$(ENGINE) prune-gate $(or $(BASE),origin/main) $(or $(HEAD),HEAD)
|
|
20
|
+
|
|
21
|
+
test:
|
|
22
|
+
$(ENGINE) test
|
|
23
|
+
|
|
24
|
+
crap:
|
|
25
|
+
$(ENGINE) crap
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"services/concentration.py": {
|
|
3
|
+
"equivalent": {},
|
|
4
|
+
"killed": 8,
|
|
5
|
+
"score": 80.0,
|
|
6
|
+
"survivors": 2,
|
|
7
|
+
"total": 10
|
|
8
|
+
},
|
|
9
|
+
"services/rebalancer.py": {
|
|
10
|
+
"equivalent": {},
|
|
11
|
+
"killed": 15,
|
|
12
|
+
"score": 42.86,
|
|
13
|
+
"survivors": 20,
|
|
14
|
+
"total": 35
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
def compute_trades(current, targets):
|
|
2
|
+
return {k: targets.get(k, 0) - current.get(k, 0) for k in set(current) | set(targets)}
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def apply_drift(drift):
|
|
6
|
+
return min(max(drift, 0), 1)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rebalance(current, targets, band=1e-6):
|
|
10
|
+
trades = compute_trades(current, targets)
|
|
11
|
+
return {k: v for k, v in trades.items() if abs(v) > band}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Concentration (HHI) tests."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from services.concentration import herfindahl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_hhi_reference():
|
|
8
|
+
assert herfindahl([0.5, 0.5]) == pytest.approx(0.5)
|
|
9
|
+
assert herfindahl([1.0]) == 1.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_hhi_case_01():
|
|
13
|
+
assert herfindahl([1 / 2] * 2) == pytest.approx(1 / 2)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_hhi_case_02():
|
|
17
|
+
assert herfindahl([1 / 3] * 3) == pytest.approx(1 / 3)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_hhi_case_03():
|
|
21
|
+
assert herfindahl([1 / 4] * 4) == pytest.approx(1 / 4)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_hhi_case_04():
|
|
25
|
+
assert herfindahl([1 / 5] * 5) == pytest.approx(1 / 5)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_hhi_case_05():
|
|
29
|
+
assert herfindahl([1 / 6] * 6) == pytest.approx(1 / 6)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_hhi_case_06():
|
|
33
|
+
assert herfindahl([1 / 7] * 7) == pytest.approx(1 / 7)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_hhi_case_07():
|
|
37
|
+
assert herfindahl([1 / 8] * 8) == pytest.approx(1 / 8)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_hhi_case_08():
|
|
41
|
+
assert herfindahl([1 / 9] * 9) == pytest.approx(1 / 9)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hhi_case_09():
|
|
45
|
+
assert herfindahl([1 / 10] * 10) == pytest.approx(1 / 10)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_hhi_case_10():
|
|
49
|
+
assert herfindahl([1 / 11] * 11) == pytest.approx(1 / 11)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_hhi_case_11():
|
|
53
|
+
assert herfindahl([1 / 12] * 12) == pytest.approx(1 / 12)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_hhi_case_12():
|
|
57
|
+
assert herfindahl([1 / 13] * 13) == pytest.approx(1 / 13)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_hhi_case_13():
|
|
61
|
+
assert herfindahl([1 / 14] * 14) == pytest.approx(1 / 14)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_hhi_case_14():
|
|
65
|
+
assert herfindahl([1 / 15] * 15) == pytest.approx(1 / 15)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_hhi_case_15():
|
|
69
|
+
assert herfindahl([1 / 16] * 16) == pytest.approx(1 / 16)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_hhi_case_16():
|
|
73
|
+
assert herfindahl([1 / 17] * 17) == pytest.approx(1 / 17)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Rebalancer tests."""
|
|
2
|
+
import math
|
|
3
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from services.rebalancer import apply_drift, compute_trades, rebalance
|
|
8
|
+
|
|
9
|
+
TOLERANCE = 1e-9
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _weights(**kw):
|
|
13
|
+
return dict(kw)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def targets():
|
|
18
|
+
return _weights(AAA=0.6, BBB=0.4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_zero_weight(targets):
|
|
22
|
+
trades = compute_trades({"AAA": 0.0}, targets)
|
|
23
|
+
assert trades["AAA"] == pytest.approx(0.6)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_small_weight(targets):
|
|
27
|
+
trades = compute_trades({"AAA": 0.01}, targets)
|
|
28
|
+
assert trades["AAA"] > 0
|
|
29
|
+
assert set(trades) == {"AAA", "BBB"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Regression: trades must net to zero (issue 412).
|
|
33
|
+
def test_compute_trades_sums_to_zero(targets):
|
|
34
|
+
trades = compute_trades({"AAA": 0.5, "BBB": 0.5}, targets)
|
|
35
|
+
assert math.isclose(sum(trades.values()), 0.0, abs_tol=TOLERANCE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_hold_when_on_target(targets):
|
|
39
|
+
assert rebalance(targets, targets) == {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_hold_when_within_band(targets):
|
|
43
|
+
assert rebalance({"AAA": 0.6000001, "BBB": 0.3999999}, targets) == {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_sell_only_rebalance(targets):
|
|
47
|
+
trades = rebalance({"AAA": 0.9, "BBB": 0.1}, targets)
|
|
48
|
+
assert trades["AAA"] < 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_trade_size_is_decimal():
|
|
52
|
+
assert isinstance(Decimal("1.5"), Decimal)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("drift", [0.1, 0.2, 0.3], ids=["low", "mid", "high"])
|
|
56
|
+
def test_apply_drift_bounds(drift):
|
|
57
|
+
assert 0 <= apply_drift(drift) <= 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestRounding:
|
|
61
|
+
"""Rounding of trade sizes."""
|
|
62
|
+
|
|
63
|
+
def test_round_half_up(self):
|
|
64
|
+
assert Decimal("0.5").quantize(Decimal("1"), ROUND_HALF_UP) == 1
|
|
65
|
+
|
|
66
|
+
@pytest.mark.slow
|
|
67
|
+
def test_round_down(self):
|
|
68
|
+
assert Decimal("0.4").quantize(Decimal("1"), ROUND_HALF_UP) == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestDriftHelpers:
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def base(self):
|
|
74
|
+
return 1.0
|
|
75
|
+
|
|
76
|
+
def test_drift_scales(self, base):
|
|
77
|
+
assert apply_drift(base) <= base
|