Goal seek (analysis.goal_seek)¶
Finds the value of one decimal input of any registered method at which one of its decimal outputs equals a target. Typical questions: the discount rate at which the DCF enterprise value equals an offer price (the implied return of the offer), or the rate at which an NPV is zero. The search runs inside an interval given by the caller, every evaluation of the inner method is reported, and the result carries the inner input at the solution so that anyone can rerun it.
Goal seek is an analysis method: it evaluates another method through the registry (see Analysis methods). It cannot evaluate another analysis method.
Formula¶
Given the inner method \(M\), the variable \(x\) (one decimal input of \(M\)), the output \(y = M(x)\) and the target \(T\), goal seek finds \(x^*\) in the bracket \([x_{low}, x_{high}]\) such that
The tolerance is absolute and measured on the output, in the output's units (currency for an enterprise value, a decimal fraction for a rate).
Algorithm¶
Brent's method in its textbook formulation, the one implemented by scipy.optimize.brentq:
- Evaluate both ends of the bracket, low first. If an end already meets the tolerance it is
returned with 0 iterations. If \(f(x_{low})\) and \(f(x_{high})\) have the same strict sign, the
target is not bracketed (
FM-E013). There is no automatic search for a wider bracket. - Each iteration takes one new point by inverse quadratic interpolation through the last three points, by the secant through the last two, or by bisection of the current bracket. An interpolated step is accepted only when it stays well inside the bracket and shrinks fast enough; otherwise the iteration bisects. An interpolation that would overflow also falls back to bisection.
- The step on the variable is never smaller than \(10^{-24} \times \max(1, \lvert x \rvert)\), which keeps each new point distinct at the 28 significant digits of the decimal type. Every new point lies strictly inside the current bracket, which always lies inside yours.
- The search stops at the first point with \(\lvert f(x) \rvert \le\)
tolerance. Ifmax_iterationspoints after the two bracket ends do not meet it, the result isFM-E014.
The output need not be monotonic, but it must be continuous in the variable over the bracket:
when the output jumps across the target, the bracket shrinks to the minimum step without
meeting the tolerance and the result is FM-E014, naming the collapsed interval, never a false
solution.
Two limits to keep in mind:
- Several solutions. When the bracket contains more than one solution (non-conventional cash flows can have several IRRs), one of them is returned, determined by the iteration path, and the result is the same on every run. Narrow the bracket to select another.
- Flat outputs. The tolerance applies to the output, not to the variable. Where the output barely moves with the variable, a point that meets the tolerance can be far from the exact solution; tighten the tolerance in that case.
Example¶
The DCF of dcf.fcff values free cash flows of 100, 110 and 120 with 2%
terminal growth. At which discount rate is the enterprise value equal to an offer of 1300?
from decimal import Decimal
import fairmark as fm
base = {
"stages": [{"periods": 3, "projection": "given", "flows": [100, 110, 120]}],
"cash_flow_terms": "nominal",
"discount_rate_terms": "nominal",
"wacc": "0.10",
"timing": "end_of_period",
"terminal": {"tv.gordon": {"growth": "0.02", "first_flow": "last_flow_grown"}},
"net_financial_position": {"gross_debt": 200, "cash": 0},
}
r = fm.analysis.goal_seek(
"dcf.fcff",
base,
variable="/wacc",
output="/enterprise_value",
target="1300",
bracket=("0.05", "0.5"),
)
print(r.solution) # 0.1073730997872031000512537941
print(r.achieved) # 1300.0000000000600441199177741
print(f"{r.residual:f}") # 0.0000000000600441199177741, within the default tolerance 0.000000001
print(r.iterations) # 9, after the two bracket ends
assert abs(r.solution - Decimal("0.107373099787207080")) < Decimal("0.000000000001")
# The same request through the generic entry point, as an agent or the CLI sends it.
same = fm.run("analysis.goal_seek", {
"method": "dcf.fcff",
"base_input": base,
"variable": "/wacc",
"output": "/enterprise_value",
"target": "1300",
"bracket": ["0.05", "0.5"],
})
assert same.solution == r.solution
# Rerun the inner method at the solution to get its full explain.
inner = fm.run(r.inner_method["id"], r.solved_input)
assert inner.value == r.inner_value
print(inner.steps[0].label) # first step of the DCF audit trail
The implied discount rate of the offer is about 10.74%. The steps of the result list the 11 evaluations in order (two bracket ends, then secant, bisection and inverse quadratic interpolation steps) with the rate, the enterprise value and the residual of each.
Inputs¶
| Field | Meaning | Default |
|---|---|---|
method |
Id of the method to evaluate, for example dcf.fcff; analysis methods are not accepted |
required |
base_input |
Input of that method, complete, exactly as the method accepts it, including the variable at any starting value | required |
variable |
RFC 6901 JSON Pointer to the decimal input to solve for, in base_input, for example /discount_rate or /cash_flows/0 |
required |
output |
RFC 6901 JSON Pointer to the decimal output, in the value of the inner result, for example /enterprise_value |
required |
target |
Value the output must reach, in the output's units | required |
bracket |
[low, high] with low < high: the interval searched for the variable, in the variable's units |
required |
tolerance |
Absolute tolerance on the output, in the output's units, greater than 0 | 0.000000001 |
max_iterations |
Largest number of evaluations after the two bracket ends, from 1 to 1,000 | 100 |
Choose the tolerance in the output's units. For an enterprise value in euro, the default of \(10^{-9}\) euro is far below any meaningful amount; for an output that is a rate, it is \(10^{-7}\) percentage points. A tolerance of \(\varepsilon\) on the output fixes the variable to about \(\varepsilon / \lvert dy/dx \rvert\).
Outputs¶
| Field | Meaning |
|---|---|
inner_method |
id and version of the method evaluated, at the version this engine ran |
solution |
Value of the variable at which the output meets the target |
achieved |
Output of the inner method at the solution |
residual |
achieved − target; its absolute value is at most the tolerance |
iterations |
Evaluations after the two bracket ends (0 when an end meets the target) |
tolerance |
Tolerance applied, in the output's units |
solved_input |
Input of the inner method at the solution, with the variable written as a decimal string |
inner_value |
value of the inner result at the solution |
solved_input and inner_value make the solution reproducible: running the inner method on
solved_input returns inner_value and the inner method's full explain, which goal seek does
not repeat.
The steps list one entry per evaluation, labelled Evaluation k (bracket end), (bisection),
(secant) or (inverse quadratic interpolation), with the variable, the output and the
residual substituted, then a final Solution step.
Conventions (explicit, reported in explain.conventions)¶
| Convention | Value | Default |
|---|---|---|
| Inner method | id and version, evaluated through the registry | required |
| Root finding | Brent's method: inverse quadratic interpolation, secant, bisection | fixed |
| Bracket | given by the caller; no automatic search | required |
| Both ends meet the tolerance | low is returned | fixed |
| Several solutions in the bracket | one is returned, determined by the iteration path | fixed |
| Stopping rule | \(\lvert \text{output} - \text{target} \rvert \le\) tolerance, absolute, in output units |
0.000000001 |
| Iteration cap | max_iterations after the two bracket ends |
100 |
| Minimum step on the variable | \(10^{-24} \times \max(1, \lvert x \rvert)\) at the current point | fixed |
| Variable encoding | written into the inner input as a decimal string | fixed |
| Evaluation order | low end, high end, then one evaluation per iteration | fixed |
The last convention reported is the number of evaluations of the inner method.
Errors¶
| Code | When |
|---|---|
FM-E001 |
low ≥ high, tolerance ≤ 0, max_iterations outside 1 to 1,000, or a field is missing or unknown |
FM-E007 |
method names no registered method |
FM-E013 |
The output minus the target has the same sign at both ends of the bracket and neither end meets the tolerance; the message reports both values |
FM-E014 |
The tolerance was not met within max_iterations, or the output jumps across the target; the message reports the closest evaluation and the interval that still brackets the target |
FM-E015 |
variable does not resolve to a decimal in base_input, or output does not resolve to a decimal in the inner result; the message names the pointer |
FM-E016 |
The request would exceed 10,000 inner evaluations; goal seek needs at most 1,002, so this code concerns the other analysis methods |
FM-E017 |
method names an analysis method |
variable is checked before any evaluation, output on the first one. An error of the inner
method at any evaluation is returned unchanged, with its own code: for example FM-E004 when a
discount rate in the bracket is at or below the terminal growth of dcf.fcff. A root search
over a partly undefined function is not defined, so choose a bracket on which the inner method
is valid.
Accuracy¶
The stopping rule bounds the residual on the output, not the error on the variable. Each evaluation is as accurate as the inner method (see its page). In the example, the solution differs from an independent 50-digit computation by about \(4 \times 10^{-15}\); the golden cases are checked to \(10^{-12}\) (DCF) and \(10^{-15}\) (IRR through NPV, with tolerance \(10^{-18}\)).
Verified against¶
- Golden cases in
tests/golden/analysis.goal_seek/, whose expected solutions come fromscripts/reference_analysis.py: bisection in Python'sdecimalat 50 digits to \(10^{-30}\) on the variable, withdcf.fcffandtvm.npvrestated from their documented formulas. It shares no code with the engine. - The rate at which the NPV of −1000, 300, 400, 500 is zero agrees with
tvm.irrand withpyxirr.irr. - The same script holds Brent's method written from its textbook formulation (Numerical
Recipes,
zbrent). A test requires the engine to reproduce its iteration count and solution, including a case with three IRRs in the bracket, so any change to the solver path is caught.