propfirm-calc 0.1.0__tar.gz
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.
- propfirm_calc-0.1.0/.github/workflows/ci.yml +22 -0
- propfirm_calc-0.1.0/.github/workflows/release.yml +28 -0
- propfirm_calc-0.1.0/.gitignore +13 -0
- propfirm_calc-0.1.0/LICENSE +21 -0
- propfirm_calc-0.1.0/PKG-INFO +155 -0
- propfirm_calc-0.1.0/README.md +111 -0
- propfirm_calc-0.1.0/pyproject.toml +42 -0
- propfirm_calc-0.1.0/src/propfirm_calc/__init__.py +34 -0
- propfirm_calc-0.1.0/src/propfirm_calc/consistency.py +49 -0
- propfirm_calc-0.1.0/src/propfirm_calc/drawdown.py +101 -0
- propfirm_calc-0.1.0/src/propfirm_calc/target.py +108 -0
- propfirm_calc-0.1.0/tests/test_consistency.py +45 -0
- propfirm_calc-0.1.0/tests/test_drawdown.py +75 -0
- propfirm_calc-0.1.0/tests/test_target.py +66 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.11", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: python -m pip install --upgrade pip
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: pytest -q
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via OIDC "trusted publishing" — no API token stored.
|
|
4
|
+
# One-time setup on pypi.org (Account -> Publishing -> Add a pending publisher):
|
|
5
|
+
# PyPI Project Name: propfirm-calc
|
|
6
|
+
# Owner: shootingallday
|
|
7
|
+
# Repository name: propfirm-calc
|
|
8
|
+
# Workflow name: release.yml
|
|
9
|
+
# Environment: (leave blank)
|
|
10
|
+
# Then publish a GitHub Release (tag vX.Y.Z) to trigger this workflow.
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
permissions:
|
|
20
|
+
id-token: write # required for trusted publishing
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.x"
|
|
26
|
+
- run: python -m pip install --upgrade build
|
|
27
|
+
- run: python -m build
|
|
28
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shootingallday
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: propfirm-calc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tiny, dependency-free math for funded-trader (prop firm) futures accounts: trailing drawdown, consistency rule, payout eligibility.
|
|
5
|
+
Project-URL: Homepage, https://github.com/shootingallday/propfirm-calc
|
|
6
|
+
Project-URL: Repository, https://github.com/shootingallday/propfirm-calc
|
|
7
|
+
Project-URL: Issues, https://github.com/shootingallday/propfirm-calc/issues
|
|
8
|
+
Author: shootingallday
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 shootingallday
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: apex,drawdown,funded-trader,futures,prop-firm,topstep,trading
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
38
|
+
Classifier: Typing :: Typed
|
|
39
|
+
Requires-Python: >=3.9
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
42
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# propfirm-calc
|
|
46
|
+
|
|
47
|
+
Tiny, dependency-free Python math for **funded-trader (prop firm) futures accounts**.
|
|
48
|
+
|
|
49
|
+
Three calculations every prop-futures trader needs and most journals get subtly
|
|
50
|
+
wrong:
|
|
51
|
+
|
|
52
|
+
1. **Trailing drawdown floor** — the equity level at which your account blows,
|
|
53
|
+
under trailing, end-of-day-trailing, or static drawdown rules.
|
|
54
|
+
2. **The consistency rule** — whether your best day is within the cap, and the
|
|
55
|
+
*total profit* a big day forces you to reach before it's withdrawable.
|
|
56
|
+
3. **Payout eligibility** — target, minimum winning days, and consistency rolled
|
|
57
|
+
into one answer with human-readable blockers.
|
|
58
|
+
|
|
59
|
+
No dependencies. No bundled firm data — you pass the numbers, so it works for
|
|
60
|
+
**any** firm (Topstep, Apex, Take Profit Trader, My Funded Futures, Lucid, …)
|
|
61
|
+
and never goes stale when a firm changes its rules.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install propfirm-calc
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Drawdown floor — the one people get wrong
|
|
70
|
+
|
|
71
|
+
The floor depends on the firm's drawdown regime. For trailing accounts it
|
|
72
|
+
follows your high-water mark *up* — until it locks at your starting balance
|
|
73
|
+
(the Topstep/Apex behavior), after which the account can never blow above
|
|
74
|
+
break-even.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from propfirm_calc import drawdown_floor, is_blown, cushion
|
|
78
|
+
|
|
79
|
+
# $50k account, $2k max loss limit, currently up $1k (peak equity $51k).
|
|
80
|
+
drawdown_floor(50_000, 2_000, peak_equity=51_000) # 49_000 (still trailing)
|
|
81
|
+
|
|
82
|
+
# Up $3k (peak $53k): the trail has locked at the $50k start.
|
|
83
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000) # 50_000 (locked)
|
|
84
|
+
|
|
85
|
+
# Static plans never trail:
|
|
86
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000, dd_type="static") # 48_000
|
|
87
|
+
|
|
88
|
+
# End-of-day trailing? Same math — just pass your highest *EOD* balance:
|
|
89
|
+
drawdown_floor(50_000, 2_000, peak_equity=51_000, dd_type="eod_trailing") # 49_000
|
|
90
|
+
|
|
91
|
+
is_blown(current_equity=48_900, starting_balance=50_000,
|
|
92
|
+
max_drawdown=2_000, peak_equity=51_000) # True
|
|
93
|
+
cushion(49_500, 50_000, 2_000, peak_equity=51_000) # 500.0 ($ before you blow)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Some firms lock the trail somewhere other than the start, or never lock at all:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000, lock_at=50_100) # 50_100
|
|
100
|
+
drawdown_floor(50_000, 2_000, peak_equity=60_000, lock_at=float("inf")) # 58_000
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Consistency rule
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from propfirm_calc import consistency_ok, best_day_pct, required_profit
|
|
107
|
+
|
|
108
|
+
best_day_pct(2_000, total_profit=5_000) # 40.0
|
|
109
|
+
consistency_ok(2_000, 5_000, consistency_pct=50) # True (40% <= 50%)
|
|
110
|
+
consistency_ok(3_000, 5_000, consistency_pct=50) # False (60% > 50%)
|
|
111
|
+
|
|
112
|
+
# A $1,500 day under a 50% rule can't be withdrawn until total profit hits $3,000:
|
|
113
|
+
required_profit(1_500, consistency_pct=50) # 3_000.0
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Payout eligibility
|
|
117
|
+
|
|
118
|
+
Pass only the constraints your firm imposes — anything omitted is skipped.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from propfirm_calc import payout_eligibility
|
|
122
|
+
|
|
123
|
+
r = payout_eligibility(
|
|
124
|
+
current_profit=4_000,
|
|
125
|
+
profit_target=3_000,
|
|
126
|
+
winning_days=4,
|
|
127
|
+
min_winning_days=5,
|
|
128
|
+
best_day_profit=3_000,
|
|
129
|
+
consistency_pct=50,
|
|
130
|
+
)
|
|
131
|
+
r.eligible # False
|
|
132
|
+
r.blockers # ('4 of 5 required winning days',
|
|
133
|
+
# 'Best day 75% over the 50% consistency limit')
|
|
134
|
+
r.consistency_required_profit # 6_000.0
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Why this exists
|
|
138
|
+
|
|
139
|
+
Prop-firm rules are simple to state and easy to mis-implement — trailing
|
|
140
|
+
drawdown that should lock but doesn't, a consistency check that ignores the
|
|
141
|
+
"effective target" a big day creates, a payout gate that forgets minimum days.
|
|
142
|
+
`propfirm-calc` is the small, well-tested core so trading journals, dashboards,
|
|
143
|
+
and bots don't each reinvent (and re-bug) it.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest -q
|
|
150
|
+
ruff check .
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# propfirm-calc
|
|
2
|
+
|
|
3
|
+
Tiny, dependency-free Python math for **funded-trader (prop firm) futures accounts**.
|
|
4
|
+
|
|
5
|
+
Three calculations every prop-futures trader needs and most journals get subtly
|
|
6
|
+
wrong:
|
|
7
|
+
|
|
8
|
+
1. **Trailing drawdown floor** — the equity level at which your account blows,
|
|
9
|
+
under trailing, end-of-day-trailing, or static drawdown rules.
|
|
10
|
+
2. **The consistency rule** — whether your best day is within the cap, and the
|
|
11
|
+
*total profit* a big day forces you to reach before it's withdrawable.
|
|
12
|
+
3. **Payout eligibility** — target, minimum winning days, and consistency rolled
|
|
13
|
+
into one answer with human-readable blockers.
|
|
14
|
+
|
|
15
|
+
No dependencies. No bundled firm data — you pass the numbers, so it works for
|
|
16
|
+
**any** firm (Topstep, Apex, Take Profit Trader, My Funded Futures, Lucid, …)
|
|
17
|
+
and never goes stale when a firm changes its rules.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install propfirm-calc
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Drawdown floor — the one people get wrong
|
|
26
|
+
|
|
27
|
+
The floor depends on the firm's drawdown regime. For trailing accounts it
|
|
28
|
+
follows your high-water mark *up* — until it locks at your starting balance
|
|
29
|
+
(the Topstep/Apex behavior), after which the account can never blow above
|
|
30
|
+
break-even.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from propfirm_calc import drawdown_floor, is_blown, cushion
|
|
34
|
+
|
|
35
|
+
# $50k account, $2k max loss limit, currently up $1k (peak equity $51k).
|
|
36
|
+
drawdown_floor(50_000, 2_000, peak_equity=51_000) # 49_000 (still trailing)
|
|
37
|
+
|
|
38
|
+
# Up $3k (peak $53k): the trail has locked at the $50k start.
|
|
39
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000) # 50_000 (locked)
|
|
40
|
+
|
|
41
|
+
# Static plans never trail:
|
|
42
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000, dd_type="static") # 48_000
|
|
43
|
+
|
|
44
|
+
# End-of-day trailing? Same math — just pass your highest *EOD* balance:
|
|
45
|
+
drawdown_floor(50_000, 2_000, peak_equity=51_000, dd_type="eod_trailing") # 49_000
|
|
46
|
+
|
|
47
|
+
is_blown(current_equity=48_900, starting_balance=50_000,
|
|
48
|
+
max_drawdown=2_000, peak_equity=51_000) # True
|
|
49
|
+
cushion(49_500, 50_000, 2_000, peak_equity=51_000) # 500.0 ($ before you blow)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Some firms lock the trail somewhere other than the start, or never lock at all:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
drawdown_floor(50_000, 2_000, peak_equity=53_000, lock_at=50_100) # 50_100
|
|
56
|
+
drawdown_floor(50_000, 2_000, peak_equity=60_000, lock_at=float("inf")) # 58_000
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Consistency rule
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from propfirm_calc import consistency_ok, best_day_pct, required_profit
|
|
63
|
+
|
|
64
|
+
best_day_pct(2_000, total_profit=5_000) # 40.0
|
|
65
|
+
consistency_ok(2_000, 5_000, consistency_pct=50) # True (40% <= 50%)
|
|
66
|
+
consistency_ok(3_000, 5_000, consistency_pct=50) # False (60% > 50%)
|
|
67
|
+
|
|
68
|
+
# A $1,500 day under a 50% rule can't be withdrawn until total profit hits $3,000:
|
|
69
|
+
required_profit(1_500, consistency_pct=50) # 3_000.0
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Payout eligibility
|
|
73
|
+
|
|
74
|
+
Pass only the constraints your firm imposes — anything omitted is skipped.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from propfirm_calc import payout_eligibility
|
|
78
|
+
|
|
79
|
+
r = payout_eligibility(
|
|
80
|
+
current_profit=4_000,
|
|
81
|
+
profit_target=3_000,
|
|
82
|
+
winning_days=4,
|
|
83
|
+
min_winning_days=5,
|
|
84
|
+
best_day_profit=3_000,
|
|
85
|
+
consistency_pct=50,
|
|
86
|
+
)
|
|
87
|
+
r.eligible # False
|
|
88
|
+
r.blockers # ('4 of 5 required winning days',
|
|
89
|
+
# 'Best day 75% over the 50% consistency limit')
|
|
90
|
+
r.consistency_required_profit # 6_000.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Why this exists
|
|
94
|
+
|
|
95
|
+
Prop-firm rules are simple to state and easy to mis-implement — trailing
|
|
96
|
+
drawdown that should lock but doesn't, a consistency check that ignores the
|
|
97
|
+
"effective target" a big day creates, a payout gate that forgets minimum days.
|
|
98
|
+
`propfirm-calc` is the small, well-tested core so trading journals, dashboards,
|
|
99
|
+
and bots don't each reinvent (and re-bug) it.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
pytest -q
|
|
106
|
+
ruff check .
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "propfirm-calc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tiny, dependency-free math for funded-trader (prop firm) futures accounts: trailing drawdown, consistency rule, payout eligibility."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "shootingallday" }]
|
|
13
|
+
keywords = ["trading", "futures", "prop-firm", "funded-trader", "drawdown", "topstep", "apex"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/shootingallday/propfirm-calc"
|
|
27
|
+
Repository = "https://github.com/shootingallday/propfirm-calc"
|
|
28
|
+
Issues = "https://github.com/shootingallday/propfirm-calc/issues"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=7", "ruff>=0.4"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/propfirm_calc"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
src = ["src", "tests"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""propfirm-calc — tiny, dependency-free math for funded-trader (prop firm) accounts.
|
|
2
|
+
|
|
3
|
+
Drawdown floors, the consistency rule, and payout eligibility — the three
|
|
4
|
+
calculations every prop-futures trader needs and most journals get subtly
|
|
5
|
+
wrong. Bring your own numbers; this library has no opinion about any specific
|
|
6
|
+
firm and bundles no firm data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .consistency import best_day_pct, consistency_ok, required_profit
|
|
12
|
+
from .drawdown import cushion, drawdown_floor, is_blown
|
|
13
|
+
from .target import (
|
|
14
|
+
EligibilityResult,
|
|
15
|
+
payout_eligibility,
|
|
16
|
+
target_reached,
|
|
17
|
+
target_remaining,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"drawdown_floor",
|
|
24
|
+
"is_blown",
|
|
25
|
+
"cushion",
|
|
26
|
+
"best_day_pct",
|
|
27
|
+
"consistency_ok",
|
|
28
|
+
"required_profit",
|
|
29
|
+
"target_remaining",
|
|
30
|
+
"target_reached",
|
|
31
|
+
"payout_eligibility",
|
|
32
|
+
"EligibilityResult",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Consistency-rule math.
|
|
2
|
+
|
|
3
|
+
Most funded-trader firms cap how much of your *total* profit a single day may
|
|
4
|
+
represent — e.g. a 50% consistency rule means your best day can be at most 50%
|
|
5
|
+
of cumulative profit. The practical consequence traders miss: a big day raises
|
|
6
|
+
the *total profit you must reach* before that day becomes withdrawable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def best_day_pct(best_day_profit: float, total_profit: float) -> float:
|
|
15
|
+
"""Best single day as a percentage of total profit.
|
|
16
|
+
|
|
17
|
+
Returns ``math.inf`` when ``total_profit`` is zero or negative, since the
|
|
18
|
+
consistency rule cannot be satisfied without positive total profit.
|
|
19
|
+
"""
|
|
20
|
+
if total_profit <= 0:
|
|
21
|
+
return math.inf
|
|
22
|
+
return best_day_profit / total_profit * 100.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def consistency_ok(
|
|
26
|
+
best_day_profit: float, total_profit: float, consistency_pct: float
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""True if the best day is within the consistency cap.
|
|
29
|
+
|
|
30
|
+
Requires positive total profit and ``best_day_pct <= consistency_pct``.
|
|
31
|
+
"""
|
|
32
|
+
if total_profit <= 0:
|
|
33
|
+
return False
|
|
34
|
+
return best_day_pct(best_day_profit, total_profit) <= consistency_pct
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def required_profit(best_day_profit: float, consistency_pct: float) -> float:
|
|
38
|
+
"""Minimum total profit at which ``best_day_profit`` satisfies the rule.
|
|
39
|
+
|
|
40
|
+
A day worth ``best_day_profit`` can only ever be ``consistency_pct`` of the
|
|
41
|
+
total, so it sets a floor on the profit you must reach:
|
|
42
|
+
``best_day_profit / (consistency_pct / 100)``.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If ``consistency_pct`` is not positive.
|
|
46
|
+
"""
|
|
47
|
+
if consistency_pct <= 0:
|
|
48
|
+
raise ValueError("consistency_pct must be a positive percentage")
|
|
49
|
+
return best_day_profit / (consistency_pct / 100.0)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Trailing / EOD-trailing / static drawdown math for funded-trader accounts.
|
|
2
|
+
|
|
3
|
+
The number traders most often get wrong is the *current* drawdown floor — the
|
|
4
|
+
equity level at which the account is breached ("blown"). It depends on the
|
|
5
|
+
firm's drawdown regime:
|
|
6
|
+
|
|
7
|
+
- ``"static"`` — a fixed floor at ``starting_balance - max_drawdown``
|
|
8
|
+
that never moves.
|
|
9
|
+
- ``"trailing"`` — the floor follows the account's highest equity point
|
|
10
|
+
(including intraday peaks), so it trails *up* as you
|
|
11
|
+
make new highs.
|
|
12
|
+
- ``"eod_trailing"`` — identical math, but the peak is the highest
|
|
13
|
+
*end-of-day* balance rather than the intraday high.
|
|
14
|
+
The only difference is what you pass as ``peak_equity``.
|
|
15
|
+
|
|
16
|
+
Most firms (Topstep, Apex, …) stop trailing once the floor reaches the starting
|
|
17
|
+
balance: from then on the floor is *locked* and the account can never blow
|
|
18
|
+
above break-even. That lock level is configurable via ``lock_at``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
_TRAILING = {"trailing", "eod_trailing"}
|
|
24
|
+
_VALID = _TRAILING | {"static"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def drawdown_floor(
|
|
28
|
+
starting_balance: float,
|
|
29
|
+
max_drawdown: float,
|
|
30
|
+
peak_equity: float,
|
|
31
|
+
dd_type: str = "trailing",
|
|
32
|
+
lock_at: float | None = None,
|
|
33
|
+
) -> float:
|
|
34
|
+
"""Return the equity level at or below which the account is breached.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
starting_balance: The account's starting balance.
|
|
38
|
+
max_drawdown: The maximum loss limit, as a positive dollar amount.
|
|
39
|
+
peak_equity: The high-water mark. For ``"trailing"`` pass the highest
|
|
40
|
+
equity ever touched (intraday included); for ``"eod_trailing"``
|
|
41
|
+
pass the highest end-of-day balance. Ignored for ``"static"``.
|
|
42
|
+
dd_type: One of ``"trailing"``, ``"eod_trailing"``, ``"static"``.
|
|
43
|
+
lock_at: The equity level at which a trailing floor stops rising and
|
|
44
|
+
locks. ``None`` (default) locks at ``starting_balance`` — the
|
|
45
|
+
common Topstep/Apex behavior. Pass ``math.inf`` for a floor that
|
|
46
|
+
never locks (keeps trailing forever).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The drawdown floor as an equity level (not a distance).
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If ``dd_type`` is unknown or ``max_drawdown`` is negative.
|
|
53
|
+
"""
|
|
54
|
+
if dd_type not in _VALID:
|
|
55
|
+
raise ValueError(f"dd_type must be one of {sorted(_VALID)}, got {dd_type!r}")
|
|
56
|
+
if max_drawdown < 0:
|
|
57
|
+
raise ValueError("max_drawdown must be a positive dollar amount")
|
|
58
|
+
|
|
59
|
+
if dd_type == "static":
|
|
60
|
+
return starting_balance - max_drawdown
|
|
61
|
+
|
|
62
|
+
raw = peak_equity - max_drawdown
|
|
63
|
+
cap = starting_balance if lock_at is None else lock_at
|
|
64
|
+
return min(raw, cap)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_blown(
|
|
68
|
+
current_equity: float,
|
|
69
|
+
starting_balance: float,
|
|
70
|
+
max_drawdown: float,
|
|
71
|
+
peak_equity: float,
|
|
72
|
+
dd_type: str = "trailing",
|
|
73
|
+
lock_at: float | None = None,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""True if ``current_equity`` has reached or fallen below the floor.
|
|
76
|
+
|
|
77
|
+
Touching the floor exactly counts as a breach (``<=``), matching how most
|
|
78
|
+
firms enforce their maximum loss limit.
|
|
79
|
+
"""
|
|
80
|
+
floor = drawdown_floor(
|
|
81
|
+
starting_balance, max_drawdown, peak_equity, dd_type=dd_type, lock_at=lock_at
|
|
82
|
+
)
|
|
83
|
+
return current_equity <= floor
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cushion(
|
|
87
|
+
current_equity: float,
|
|
88
|
+
starting_balance: float,
|
|
89
|
+
max_drawdown: float,
|
|
90
|
+
peak_equity: float,
|
|
91
|
+
dd_type: str = "trailing",
|
|
92
|
+
lock_at: float | None = None,
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Dollars of room before the account blows.
|
|
95
|
+
|
|
96
|
+
Positive means safe; zero or negative means the floor has been reached.
|
|
97
|
+
"""
|
|
98
|
+
floor = drawdown_floor(
|
|
99
|
+
starting_balance, max_drawdown, peak_equity, dd_type=dd_type, lock_at=lock_at
|
|
100
|
+
)
|
|
101
|
+
return current_equity - floor
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Profit-target progress and payout eligibility.
|
|
2
|
+
|
|
3
|
+
``payout_eligibility`` combines the target, minimum-winning-days, and
|
|
4
|
+
consistency checks into a single answer plus human-readable blockers. Pass
|
|
5
|
+
only the constraints your firm imposes; anything left as ``None`` is skipped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .consistency import best_day_pct, consistency_ok, required_profit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def target_remaining(current_profit: float, profit_target: float) -> float:
|
|
16
|
+
"""Dollars still needed to reach the profit target (never negative)."""
|
|
17
|
+
return max(0.0, profit_target - current_profit)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def target_reached(current_profit: float, profit_target: float) -> bool:
|
|
21
|
+
"""True once cumulative profit meets or exceeds the target."""
|
|
22
|
+
return current_profit >= profit_target
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EligibilityResult:
|
|
27
|
+
"""Outcome of :func:`payout_eligibility`.
|
|
28
|
+
|
|
29
|
+
``*_met`` fields are ``None`` when that check was not requested.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
eligible: bool
|
|
33
|
+
blockers: tuple[str, ...]
|
|
34
|
+
target_met: bool | None = None
|
|
35
|
+
days_met: bool | None = None
|
|
36
|
+
consistency_met: bool | None = None
|
|
37
|
+
profit_remaining: float | None = None
|
|
38
|
+
consistency_required_profit: float | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def payout_eligibility(
|
|
42
|
+
current_profit: float,
|
|
43
|
+
*,
|
|
44
|
+
profit_target: float | None = None,
|
|
45
|
+
winning_days: int | None = None,
|
|
46
|
+
min_winning_days: int | None = None,
|
|
47
|
+
best_day_profit: float | None = None,
|
|
48
|
+
consistency_pct: float | None = None,
|
|
49
|
+
) -> EligibilityResult:
|
|
50
|
+
"""Evaluate whether a funded account currently qualifies for a payout.
|
|
51
|
+
|
|
52
|
+
Only the constraints you supply are enforced. The result is ``eligible``
|
|
53
|
+
when no blockers remain.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
current_profit: Cumulative net profit on the account.
|
|
57
|
+
profit_target: Profit target to clear, if the firm sets one.
|
|
58
|
+
winning_days / min_winning_days: Count of qualifying winning days and
|
|
59
|
+
the minimum required. Both must be given for the check to apply.
|
|
60
|
+
best_day_profit / consistency_pct: Largest single-day profit and the
|
|
61
|
+
consistency cap. Both must be given for the check to apply.
|
|
62
|
+
"""
|
|
63
|
+
blockers: list[str] = []
|
|
64
|
+
|
|
65
|
+
target_met: bool | None = None
|
|
66
|
+
profit_remaining: float | None = None
|
|
67
|
+
if profit_target is not None:
|
|
68
|
+
target_met = target_reached(current_profit, profit_target)
|
|
69
|
+
profit_remaining = target_remaining(current_profit, profit_target)
|
|
70
|
+
if not target_met:
|
|
71
|
+
blockers.append(
|
|
72
|
+
f"Profit ${current_profit:,.0f} below target ${profit_target:,.0f} "
|
|
73
|
+
f"(${profit_remaining:,.0f} to go)"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
days_met: bool | None = None
|
|
77
|
+
if winning_days is not None and min_winning_days is not None:
|
|
78
|
+
days_met = winning_days >= min_winning_days
|
|
79
|
+
if not days_met:
|
|
80
|
+
blockers.append(
|
|
81
|
+
f"{winning_days} of {min_winning_days} required winning days"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
consistency_met: bool | None = None
|
|
85
|
+
consistency_required_profit: float | None = None
|
|
86
|
+
if best_day_profit is not None and consistency_pct is not None:
|
|
87
|
+
consistency_met = consistency_ok(
|
|
88
|
+
best_day_profit, current_profit, consistency_pct
|
|
89
|
+
)
|
|
90
|
+
consistency_required_profit = required_profit(
|
|
91
|
+
best_day_profit, consistency_pct
|
|
92
|
+
)
|
|
93
|
+
if not consistency_met:
|
|
94
|
+
pct = best_day_pct(best_day_profit, current_profit)
|
|
95
|
+
shown = "∞" if pct == float("inf") else f"{pct:.0f}%"
|
|
96
|
+
blockers.append(
|
|
97
|
+
f"Best day {shown} over the {consistency_pct:.0f}% consistency limit"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return EligibilityResult(
|
|
101
|
+
eligible=not blockers,
|
|
102
|
+
blockers=tuple(blockers),
|
|
103
|
+
target_met=target_met,
|
|
104
|
+
days_met=days_met,
|
|
105
|
+
consistency_met=consistency_met,
|
|
106
|
+
profit_remaining=profit_remaining,
|
|
107
|
+
consistency_required_profit=consistency_required_profit,
|
|
108
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from propfirm_calc import best_day_pct, consistency_ok, required_profit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBestDayPct:
|
|
9
|
+
def test_basic_share(self):
|
|
10
|
+
assert best_day_pct(1_500, 3_000) == 50.0
|
|
11
|
+
|
|
12
|
+
def test_zero_total_is_infinite(self):
|
|
13
|
+
assert best_day_pct(500, 0) == math.inf
|
|
14
|
+
|
|
15
|
+
def test_negative_total_is_infinite(self):
|
|
16
|
+
assert best_day_pct(500, -200) == math.inf
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestConsistencyOk:
|
|
20
|
+
def test_within_cap_passes(self):
|
|
21
|
+
# Best day is 40% of total, under a 50% cap.
|
|
22
|
+
assert consistency_ok(2_000, 5_000, 50) is True
|
|
23
|
+
|
|
24
|
+
def test_at_cap_passes(self):
|
|
25
|
+
assert consistency_ok(2_500, 5_000, 50) is True
|
|
26
|
+
|
|
27
|
+
def test_over_cap_fails(self):
|
|
28
|
+
assert consistency_ok(3_000, 5_000, 50) is False
|
|
29
|
+
|
|
30
|
+
def test_zero_total_fails(self):
|
|
31
|
+
assert consistency_ok(0, 0, 50) is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestRequiredProfit:
|
|
35
|
+
def test_effective_target_floor(self):
|
|
36
|
+
# A $1,500 day under a 50% rule needs $3,000 total before it's clean.
|
|
37
|
+
assert required_profit(1_500, 50) == 3_000
|
|
38
|
+
|
|
39
|
+
def test_tighter_rule_needs_more(self):
|
|
40
|
+
# Same day under a 30% rule needs $5,000.
|
|
41
|
+
assert required_profit(1_500, 30) == pytest.approx(5_000)
|
|
42
|
+
|
|
43
|
+
def test_non_positive_pct_raises(self):
|
|
44
|
+
with pytest.raises(ValueError):
|
|
45
|
+
required_profit(1_500, 0)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from propfirm_calc import cushion, drawdown_floor, is_blown
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestStatic:
|
|
9
|
+
def test_floor_is_fixed_at_start_minus_dd(self):
|
|
10
|
+
# A static $50k account with a $2k drawdown: floor never moves off 48k.
|
|
11
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=60_000, dd_type="static") == 48_000
|
|
12
|
+
|
|
13
|
+
def test_peak_is_ignored_for_static(self):
|
|
14
|
+
low = drawdown_floor(50_000, 2_000, peak_equity=50_000, dd_type="static")
|
|
15
|
+
high = drawdown_floor(50_000, 2_000, peak_equity=99_000, dd_type="static")
|
|
16
|
+
assert low == high == 48_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestTrailing:
|
|
20
|
+
def test_floor_trails_the_peak_before_locking(self):
|
|
21
|
+
# Up $1k (peak 51k) on a 50k/2k account: floor = 51k - 2k = 49k, still
|
|
22
|
+
# below the 50k lock, so it trails.
|
|
23
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=51_000) == 49_000
|
|
24
|
+
|
|
25
|
+
def test_floor_locks_at_starting_balance(self):
|
|
26
|
+
# Once peak - dd would exceed the start, the floor locks at start.
|
|
27
|
+
# peak 53k -> raw 51k, capped at 50k.
|
|
28
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=53_000) == 50_000
|
|
29
|
+
|
|
30
|
+
def test_floor_at_exactly_the_lock_point(self):
|
|
31
|
+
# peak 52k -> raw 50k == start: locked exactly at start.
|
|
32
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=52_000) == 50_000
|
|
33
|
+
|
|
34
|
+
def test_initial_floor_when_peak_equals_start(self):
|
|
35
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=50_000) == 48_000
|
|
36
|
+
|
|
37
|
+
def test_custom_lock_level(self):
|
|
38
|
+
# A firm that locks the trail at start + $100 instead of start.
|
|
39
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=53_000, lock_at=50_100) == 50_100
|
|
40
|
+
|
|
41
|
+
def test_never_locking_floor_keeps_trailing(self):
|
|
42
|
+
# lock_at=inf -> pure trailing, floor can rise into profit.
|
|
43
|
+
assert drawdown_floor(50_000, 2_000, peak_equity=60_000, lock_at=math.inf) == 58_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestEodTrailing:
|
|
47
|
+
def test_same_math_as_trailing_given_eod_peak(self):
|
|
48
|
+
# eod_trailing differs only in *which* peak you pass; the math matches.
|
|
49
|
+
intraday = drawdown_floor(100_000, 3_000, peak_equity=104_000, dd_type="trailing")
|
|
50
|
+
eod = drawdown_floor(100_000, 3_000, peak_equity=104_000, dd_type="eod_trailing")
|
|
51
|
+
assert intraday == eod == 100_000 # raw 101k capped at 100k start
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestIsBlownAndCushion:
|
|
55
|
+
def test_not_blown_with_room(self):
|
|
56
|
+
assert is_blown(49_500, 50_000, 2_000, peak_equity=51_000) is False
|
|
57
|
+
assert cushion(49_500, 50_000, 2_000, peak_equity=51_000) == 500
|
|
58
|
+
|
|
59
|
+
def test_blown_below_floor(self):
|
|
60
|
+
assert is_blown(48_900, 50_000, 2_000, peak_equity=51_000) is True
|
|
61
|
+
|
|
62
|
+
def test_touching_floor_exactly_is_a_breach(self):
|
|
63
|
+
floor = drawdown_floor(50_000, 2_000, peak_equity=51_000) # 49_000
|
|
64
|
+
assert is_blown(floor, 50_000, 2_000, peak_equity=51_000) is True
|
|
65
|
+
assert cushion(floor, 50_000, 2_000, peak_equity=51_000) == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestValidation:
|
|
69
|
+
def test_unknown_dd_type_raises(self):
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
drawdown_floor(50_000, 2_000, 50_000, dd_type="weird")
|
|
72
|
+
|
|
73
|
+
def test_negative_max_drawdown_raises(self):
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
drawdown_floor(50_000, -1, 50_000)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from propfirm_calc import payout_eligibility, target_reached, target_remaining
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestTarget:
|
|
5
|
+
def test_remaining_never_negative(self):
|
|
6
|
+
assert target_remaining(2_500, 3_000) == 500
|
|
7
|
+
assert target_remaining(4_000, 3_000) == 0
|
|
8
|
+
|
|
9
|
+
def test_reached(self):
|
|
10
|
+
assert target_reached(3_000, 3_000) is True
|
|
11
|
+
assert target_reached(2_999, 3_000) is False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestPayoutEligibility:
|
|
15
|
+
def test_all_checks_pass(self):
|
|
16
|
+
r = payout_eligibility(
|
|
17
|
+
6_000,
|
|
18
|
+
profit_target=3_000,
|
|
19
|
+
winning_days=10,
|
|
20
|
+
min_winning_days=5,
|
|
21
|
+
best_day_profit=2_000,
|
|
22
|
+
consistency_pct=50,
|
|
23
|
+
)
|
|
24
|
+
assert r.eligible is True
|
|
25
|
+
assert r.blockers == ()
|
|
26
|
+
assert r.target_met is True
|
|
27
|
+
assert r.days_met is True
|
|
28
|
+
assert r.consistency_met is True
|
|
29
|
+
|
|
30
|
+
def test_target_blocker(self):
|
|
31
|
+
r = payout_eligibility(2_000, profit_target=3_000)
|
|
32
|
+
assert r.eligible is False
|
|
33
|
+
assert r.target_met is False
|
|
34
|
+
assert r.profit_remaining == 1_000
|
|
35
|
+
assert "below target" in r.blockers[0]
|
|
36
|
+
|
|
37
|
+
def test_winning_days_blocker(self):
|
|
38
|
+
r = payout_eligibility(5_000, winning_days=3, min_winning_days=5)
|
|
39
|
+
assert r.eligible is False
|
|
40
|
+
assert r.days_met is False
|
|
41
|
+
assert "3 of 5" in r.blockers[0]
|
|
42
|
+
|
|
43
|
+
def test_consistency_blocker_reports_required_profit(self):
|
|
44
|
+
r = payout_eligibility(4_000, best_day_profit=3_000, consistency_pct=50)
|
|
45
|
+
assert r.eligible is False
|
|
46
|
+
assert r.consistency_met is False
|
|
47
|
+
# $3k day under 50% needs $6k total before it's clean.
|
|
48
|
+
assert r.consistency_required_profit == 6_000
|
|
49
|
+
assert "consistency limit" in r.blockers[0]
|
|
50
|
+
|
|
51
|
+
def test_unrequested_checks_are_none(self):
|
|
52
|
+
r = payout_eligibility(5_000)
|
|
53
|
+
assert r.eligible is True
|
|
54
|
+
assert r.target_met is None
|
|
55
|
+
assert r.days_met is None
|
|
56
|
+
assert r.consistency_met is None
|
|
57
|
+
|
|
58
|
+
def test_multiple_blockers_accumulate(self):
|
|
59
|
+
r = payout_eligibility(
|
|
60
|
+
1_000,
|
|
61
|
+
profit_target=3_000,
|
|
62
|
+
winning_days=2,
|
|
63
|
+
min_winning_days=5,
|
|
64
|
+
)
|
|
65
|
+
assert r.eligible is False
|
|
66
|
+
assert len(r.blockers) == 2
|