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.
@@ -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,13 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .venv/
11
+ venv/
12
+ .coverage
13
+ htmlcov/
@@ -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