crudecode-valuation 0.2.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.
- crudecode_valuation-0.2.0/.gitignore +24 -0
- crudecode_valuation-0.2.0/LICENSE +21 -0
- crudecode_valuation-0.2.0/PKG-INFO +136 -0
- crudecode_valuation-0.2.0/README.md +117 -0
- crudecode_valuation-0.2.0/crude_valuation/README.md +146 -0
- crudecode_valuation-0.2.0/crude_valuation/__init__.py +51 -0
- crudecode_valuation-0.2.0/crude_valuation/casefile.py +104 -0
- crudecode_valuation-0.2.0/crude_valuation/compose.py +79 -0
- crudecode_valuation-0.2.0/crude_valuation/econ/__init__.py +4 -0
- crudecode_valuation-0.2.0/crude_valuation/econ/cashflow.py +75 -0
- crudecode_valuation-0.2.0/crude_valuation/econ/npv.py +14 -0
- crudecode_valuation-0.2.0/crude_valuation/econ/revenue.py +39 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/README.md +142 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/__init__.py +37 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/analogs.py +50 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/curve.py +212 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/diagnostics.py +245 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/examples/__init__.py +2 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/examples/forecast_analogs.py +94 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/examples/forecast_historical.py +50 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/examples/forecast_mixed.py +199 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/forecast.py +84 -0
- crudecode_valuation-0.2.0/crude_valuation/forecast/wells.py +152 -0
- crudecode_valuation-0.2.0/pyproject.toml +31 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.venv/
|
|
3
|
+
sessions/
|
|
4
|
+
logs/
|
|
5
|
+
test_output/
|
|
6
|
+
node_modules/
|
|
7
|
+
__pycache__/
|
|
8
|
+
.DS_Store
|
|
9
|
+
*.pyc
|
|
10
|
+
renderer/src/App.css
|
|
11
|
+
renderer/src/assets/
|
|
12
|
+
renderer/public/icons.svg
|
|
13
|
+
repomix-*.txt
|
|
14
|
+
managed_agents/.cache.json
|
|
15
|
+
managed_agents/output/
|
|
16
|
+
|
|
17
|
+
# Sibling/scratch dirs (not part of MCP server)
|
|
18
|
+
.superpowers/
|
|
19
|
+
planning_notes.md
|
|
20
|
+
.worktrees/
|
|
21
|
+
|
|
22
|
+
# Marker written by deploy.sh on the EC2 host — the SHA the running MCP
|
|
23
|
+
# was last deployed against. Never committed.
|
|
24
|
+
.last-mcp-deployed-sha
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crude Code
|
|
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 OF OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crudecode-valuation
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Oil & gas deal valuation toolkit for Crude Code inner agents — forecast, cashflow, NPV.
|
|
5
|
+
Project-URL: Homepage, https://github.com/petroleumPythoneer/crudecode
|
|
6
|
+
Author: Crude Code
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: crudecode-analyst>=0.5.0
|
|
15
|
+
Requires-Dist: numpy>=1.24
|
|
16
|
+
Requires-Dist: pandas>=2.0
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# crudecode-valuation
|
|
21
|
+
|
|
22
|
+
Domain package for oil & gas deal valuation. Used by the `valuation_agent`
|
|
23
|
+
inner agent in the EI Plugins MCP server.
|
|
24
|
+
|
|
25
|
+
## What you get
|
|
26
|
+
|
|
27
|
+
Two subsystems:
|
|
28
|
+
|
|
29
|
+
- **`forecast`** — primitives + diagnostic helpers for building production
|
|
30
|
+
forecasts from analogs, own history, or blends.
|
|
31
|
+
- **`econ`** — revenue → cashflow → NPV given a forecast tuple, interest
|
|
32
|
+
type, and economic assumptions.
|
|
33
|
+
|
|
34
|
+
You compose them yourself. No fused `valuate()` entry point.
|
|
35
|
+
|
|
36
|
+
## Agent workflow — EXPLORE → DECIDE → EXECUTE
|
|
37
|
+
|
|
38
|
+
Every `/tmp/valuation.py` follows three phases marked by comment headers.
|
|
39
|
+
|
|
40
|
+
### EXPLORE
|
|
41
|
+
|
|
42
|
+
Load production, find analogs, fit them, and inspect with the three
|
|
43
|
+
diagnostic helpers:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from crude_valuation import (
|
|
47
|
+
load_production, find_analogs, fit_curve,
|
|
48
|
+
cohort_summary, fit_quality, target_vs_cohort,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
prod = load_production(WELL_APIS, run_sql)
|
|
52
|
+
analog_apis = find_analogs(ANALOG_FILTER, run_sql, exclude=WELL_APIS)
|
|
53
|
+
analog_curves = []
|
|
54
|
+
for a in analog_apis:
|
|
55
|
+
try:
|
|
56
|
+
analog_prod = load_production([a], run_sql)
|
|
57
|
+
q = np.array(analog_prod["oil_bbl"])
|
|
58
|
+
months = np.arange(len(q), dtype=float)
|
|
59
|
+
meta = peek_well(a, run_sql)
|
|
60
|
+
analog_curves.append(
|
|
61
|
+
fit_curve(months, q, stream="oil", lateral_norm_ft=meta.lateral_ft, b_fixed=0.8)
|
|
62
|
+
)
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue # skip thin / un-fittable analogs
|
|
65
|
+
|
|
66
|
+
print(cohort_summary(analog_curves))
|
|
67
|
+
cohort_median = percentile_curves(analog_curves, pct=0.5, norm_to_lateral_ft=10_000.0)
|
|
68
|
+
|
|
69
|
+
for api in WELL_APIS:
|
|
70
|
+
meta = peek_well(api, run_sql)
|
|
71
|
+
target_prod = load_production([api], run_sql)
|
|
72
|
+
target_q = np.array(target_prod["oil_bbl"])
|
|
73
|
+
target_months = np.arange(len(target_q), dtype=float)
|
|
74
|
+
print(api, target_vs_cohort(cohort_median, target_months, target_q, meta.lateral_ft))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### DECIDE
|
|
78
|
+
|
|
79
|
+
A free-form comment block. Document the call per well in plain English:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# === DECIDE ===
|
|
83
|
+
# Cohort: 15 analogs after dropping 3 bound-riders. b median 0.92, IQR 0.31.
|
|
84
|
+
# Target 42-329-12345 (Wolfcamp A, 9,400 ft):
|
|
85
|
+
# 36 months observed, qi_ratio 1.04 -> in line with cohort
|
|
86
|
+
# Choice: pdp; fit own decline with b=0.9 (cohort median, dropped outliers)
|
|
87
|
+
# Target 42-329-12999 (PERMITTED, 10,200 ft):
|
|
88
|
+
# no history; planned spud 2026-08
|
|
89
|
+
# Choice: pure_analog with cohort curve at target lateral
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### EXECUTE
|
|
93
|
+
|
|
94
|
+
Build Forecasts inline per-well — no router, no module constants:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# === EXECUTE ===
|
|
98
|
+
forecasts_oil = []
|
|
99
|
+
forecasts_gas = []
|
|
100
|
+
for api, choice in CHOICES.items():
|
|
101
|
+
if choice["strategy"] == "pdp":
|
|
102
|
+
forecasts_oil.append(_build_pdp(api, b=choice["b"], stream="oil", ...))
|
|
103
|
+
elif choice["strategy"] == "thin_blend":
|
|
104
|
+
forecasts_oil.append(_build_thin(api, cohort_median_oil, ...))
|
|
105
|
+
else: # pure_analog
|
|
106
|
+
forecasts_oil.append(_build_pure_analog(api, cohort_median_oil, ...))
|
|
107
|
+
# same for gas
|
|
108
|
+
|
|
109
|
+
# Econ + persist
|
|
110
|
+
revenue = compute_gross_revenue(forecasts_oil, forecasts_gas, price_deck=...)
|
|
111
|
+
cashflow = compute_net_cashflow(revenue, interest_type=INTEREST_TYPE, ...)
|
|
112
|
+
npv_dict = npv(cashflow, discount_rates=[0.08, 0.10, 0.12, 0.15])
|
|
113
|
+
|
|
114
|
+
spec = build_minimal_briefing(headline=..., commentary=..., npv=npv_dict, ...)
|
|
115
|
+
persist(spec, persist_url="$persist_url")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Commentary must reference at least one concrete diagnostic you looked at.
|
|
119
|
+
|
|
120
|
+
## Playbooks
|
|
121
|
+
|
|
122
|
+
`crude_valuation/forecast/examples/` ships annotated playbooks showing the
|
|
123
|
+
EXPLORE → DECIDE → EXECUTE pattern end-to-end. Today only `forecast_mixed.py`
|
|
124
|
+
is in the new playbook form; the other two are still recipe-style.
|
|
125
|
+
|
|
126
|
+
## Diagnostic helpers in detail
|
|
127
|
+
|
|
128
|
+
- `cohort_summary(curves)` — distribution stats per fitted parameter (qi, di,
|
|
129
|
+
b, terminal, lateral). Plus `notes` flagging fits that rode the b bound.
|
|
130
|
+
- `fit_quality(curve, months, q)` — R^2, RMSE, bound-riding flags, max
|
|
131
|
+
residual %, early/late signed-% bias windows for a single fit.
|
|
132
|
+
- `target_vs_cohort(cohort, target_months, target_q, target_lateral_ft)` —
|
|
133
|
+
qi_ratio of target to cohort, peak detection, per-month signed-% residual
|
|
134
|
+
vs cohort placed at target lateral.
|
|
135
|
+
|
|
136
|
+
None of these decide anything. The agent reads them and chooses.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# crudecode-valuation
|
|
2
|
+
|
|
3
|
+
Domain package for oil & gas deal valuation. Used by the `valuation_agent`
|
|
4
|
+
inner agent in the EI Plugins MCP server.
|
|
5
|
+
|
|
6
|
+
## What you get
|
|
7
|
+
|
|
8
|
+
Two subsystems:
|
|
9
|
+
|
|
10
|
+
- **`forecast`** — primitives + diagnostic helpers for building production
|
|
11
|
+
forecasts from analogs, own history, or blends.
|
|
12
|
+
- **`econ`** — revenue → cashflow → NPV given a forecast tuple, interest
|
|
13
|
+
type, and economic assumptions.
|
|
14
|
+
|
|
15
|
+
You compose them yourself. No fused `valuate()` entry point.
|
|
16
|
+
|
|
17
|
+
## Agent workflow — EXPLORE → DECIDE → EXECUTE
|
|
18
|
+
|
|
19
|
+
Every `/tmp/valuation.py` follows three phases marked by comment headers.
|
|
20
|
+
|
|
21
|
+
### EXPLORE
|
|
22
|
+
|
|
23
|
+
Load production, find analogs, fit them, and inspect with the three
|
|
24
|
+
diagnostic helpers:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from crude_valuation import (
|
|
28
|
+
load_production, find_analogs, fit_curve,
|
|
29
|
+
cohort_summary, fit_quality, target_vs_cohort,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
prod = load_production(WELL_APIS, run_sql)
|
|
33
|
+
analog_apis = find_analogs(ANALOG_FILTER, run_sql, exclude=WELL_APIS)
|
|
34
|
+
analog_curves = []
|
|
35
|
+
for a in analog_apis:
|
|
36
|
+
try:
|
|
37
|
+
analog_prod = load_production([a], run_sql)
|
|
38
|
+
q = np.array(analog_prod["oil_bbl"])
|
|
39
|
+
months = np.arange(len(q), dtype=float)
|
|
40
|
+
meta = peek_well(a, run_sql)
|
|
41
|
+
analog_curves.append(
|
|
42
|
+
fit_curve(months, q, stream="oil", lateral_norm_ft=meta.lateral_ft, b_fixed=0.8)
|
|
43
|
+
)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue # skip thin / un-fittable analogs
|
|
46
|
+
|
|
47
|
+
print(cohort_summary(analog_curves))
|
|
48
|
+
cohort_median = percentile_curves(analog_curves, pct=0.5, norm_to_lateral_ft=10_000.0)
|
|
49
|
+
|
|
50
|
+
for api in WELL_APIS:
|
|
51
|
+
meta = peek_well(api, run_sql)
|
|
52
|
+
target_prod = load_production([api], run_sql)
|
|
53
|
+
target_q = np.array(target_prod["oil_bbl"])
|
|
54
|
+
target_months = np.arange(len(target_q), dtype=float)
|
|
55
|
+
print(api, target_vs_cohort(cohort_median, target_months, target_q, meta.lateral_ft))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### DECIDE
|
|
59
|
+
|
|
60
|
+
A free-form comment block. Document the call per well in plain English:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# === DECIDE ===
|
|
64
|
+
# Cohort: 15 analogs after dropping 3 bound-riders. b median 0.92, IQR 0.31.
|
|
65
|
+
# Target 42-329-12345 (Wolfcamp A, 9,400 ft):
|
|
66
|
+
# 36 months observed, qi_ratio 1.04 -> in line with cohort
|
|
67
|
+
# Choice: pdp; fit own decline with b=0.9 (cohort median, dropped outliers)
|
|
68
|
+
# Target 42-329-12999 (PERMITTED, 10,200 ft):
|
|
69
|
+
# no history; planned spud 2026-08
|
|
70
|
+
# Choice: pure_analog with cohort curve at target lateral
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### EXECUTE
|
|
74
|
+
|
|
75
|
+
Build Forecasts inline per-well — no router, no module constants:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# === EXECUTE ===
|
|
79
|
+
forecasts_oil = []
|
|
80
|
+
forecasts_gas = []
|
|
81
|
+
for api, choice in CHOICES.items():
|
|
82
|
+
if choice["strategy"] == "pdp":
|
|
83
|
+
forecasts_oil.append(_build_pdp(api, b=choice["b"], stream="oil", ...))
|
|
84
|
+
elif choice["strategy"] == "thin_blend":
|
|
85
|
+
forecasts_oil.append(_build_thin(api, cohort_median_oil, ...))
|
|
86
|
+
else: # pure_analog
|
|
87
|
+
forecasts_oil.append(_build_pure_analog(api, cohort_median_oil, ...))
|
|
88
|
+
# same for gas
|
|
89
|
+
|
|
90
|
+
# Econ + persist
|
|
91
|
+
revenue = compute_gross_revenue(forecasts_oil, forecasts_gas, price_deck=...)
|
|
92
|
+
cashflow = compute_net_cashflow(revenue, interest_type=INTEREST_TYPE, ...)
|
|
93
|
+
npv_dict = npv(cashflow, discount_rates=[0.08, 0.10, 0.12, 0.15])
|
|
94
|
+
|
|
95
|
+
spec = build_minimal_briefing(headline=..., commentary=..., npv=npv_dict, ...)
|
|
96
|
+
persist(spec, persist_url="$persist_url")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Commentary must reference at least one concrete diagnostic you looked at.
|
|
100
|
+
|
|
101
|
+
## Playbooks
|
|
102
|
+
|
|
103
|
+
`crude_valuation/forecast/examples/` ships annotated playbooks showing the
|
|
104
|
+
EXPLORE → DECIDE → EXECUTE pattern end-to-end. Today only `forecast_mixed.py`
|
|
105
|
+
is in the new playbook form; the other two are still recipe-style.
|
|
106
|
+
|
|
107
|
+
## Diagnostic helpers in detail
|
|
108
|
+
|
|
109
|
+
- `cohort_summary(curves)` — distribution stats per fitted parameter (qi, di,
|
|
110
|
+
b, terminal, lateral). Plus `notes` flagging fits that rode the b bound.
|
|
111
|
+
- `fit_quality(curve, months, q)` — R^2, RMSE, bound-riding flags, max
|
|
112
|
+
residual %, early/late signed-% bias windows for a single fit.
|
|
113
|
+
- `target_vs_cohort(cohort, target_months, target_q, target_lateral_ft)` —
|
|
114
|
+
qi_ratio of target to cohort, peak detection, per-month signed-% residual
|
|
115
|
+
vs cohort placed at target lateral.
|
|
116
|
+
|
|
117
|
+
None of these decide anything. The agent reads them and chooses.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# crude_valuation
|
|
2
|
+
|
|
3
|
+
In-package cookbook the inner valuation_agent reads at runtime. Walks
|
|
4
|
+
through the three-phase workflow and the diagnostic helpers.
|
|
5
|
+
|
|
6
|
+
The package has two subsystems:
|
|
7
|
+
|
|
8
|
+
- **`forecast/`** — produce `(forecasts_oil, forecasts_gas)`. Three
|
|
9
|
+
diagnostic helpers (`cohort_summary`, `fit_quality`,
|
|
10
|
+
`target_vs_cohort`) let you inspect the cohort and target wells before
|
|
11
|
+
committing to forecast parameters.
|
|
12
|
+
- **`econ/`** — turn forecasts into money. Revenue → cashflow → NPV.
|
|
13
|
+
|
|
14
|
+
Forecast and econ are deliberately separate. The forecast step's
|
|
15
|
+
deliverable is `(forecasts_oil, forecasts_gas)` — that tuple is the input
|
|
16
|
+
to the econ step. There is no fused entry point.
|
|
17
|
+
|
|
18
|
+
## Imports (flat)
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from crude_valuation import (
|
|
22
|
+
# Forecast types and primitives
|
|
23
|
+
DeclineCurve, Forecast, ForecastProvenance,
|
|
24
|
+
fit_curve, percentile_curves, blend_curves, curve_rate,
|
|
25
|
+
project, aggregate,
|
|
26
|
+
peek_well, load_production, find_analogs,
|
|
27
|
+
# Diagnostics
|
|
28
|
+
cohort_summary, fit_quality, target_vs_cohort,
|
|
29
|
+
CohortSummary, FitQuality, TargetComparison, ParamStats,
|
|
30
|
+
# Econ
|
|
31
|
+
compute_gross_revenue, compute_net_cashflow, npv,
|
|
32
|
+
# Briefing assembly
|
|
33
|
+
build_minimal_briefing,
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Workflow — EXPLORE → DECIDE → EXECUTE
|
|
38
|
+
|
|
39
|
+
Every `/tmp/valuation.py` you write is structured into three phases marked
|
|
40
|
+
by comment headers. You think like a petroleum engineer: inspect, decide,
|
|
41
|
+
execute. Document the calls you made in plain English so a reader of the
|
|
42
|
+
briefing can audit them.
|
|
43
|
+
|
|
44
|
+
### EXPLORE — load data, fit analogs, print diagnostics
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import numpy as np
|
|
48
|
+
from crude_valuation import (
|
|
49
|
+
load_production, find_analogs, fit_curve, peek_well,
|
|
50
|
+
percentile_curves,
|
|
51
|
+
cohort_summary, fit_quality, target_vs_cohort,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
prod = load_production(WELL_APIS, run_sql)
|
|
55
|
+
analog_apis = find_analogs(ANALOG_FILTER, run_sql, exclude=WELL_APIS)
|
|
56
|
+
|
|
57
|
+
analog_curves = []
|
|
58
|
+
for a in analog_apis:
|
|
59
|
+
try:
|
|
60
|
+
analog_prod = load_production([a], run_sql)
|
|
61
|
+
q = np.array(analog_prod["oil_bbl"])
|
|
62
|
+
months = np.arange(len(q), dtype=float)
|
|
63
|
+
meta = peek_well(a, run_sql)
|
|
64
|
+
analog_curves.append(
|
|
65
|
+
fit_curve(months, q, stream="oil", lateral_norm_ft=meta.lateral_ft, b_fixed=0.8)
|
|
66
|
+
)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue # skip thin / un-fittable analogs
|
|
69
|
+
|
|
70
|
+
print(cohort_summary(analog_curves)) # AGENT READS THIS
|
|
71
|
+
cohort_median = percentile_curves(analog_curves, pct=0.5, norm_to_lateral_ft=10_000.0)
|
|
72
|
+
|
|
73
|
+
for api in WELL_APIS:
|
|
74
|
+
meta = peek_well(api, run_sql)
|
|
75
|
+
target_prod = load_production([api], run_sql)
|
|
76
|
+
target_q = np.array(target_prod["oil_bbl"])
|
|
77
|
+
target_months = np.arange(len(target_q), dtype=float)
|
|
78
|
+
print(api, target_vs_cohort(cohort_median, target_months, target_q, meta.lateral_ft))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For PDP candidates (wells you'll fit on their own history), also print
|
|
82
|
+
`fit_quality(own_curve, months, q)` so you can gate fits that rode the
|
|
83
|
+
b bound.
|
|
84
|
+
|
|
85
|
+
### DECIDE — write a comment block per well
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# === DECIDE ===
|
|
89
|
+
# Cohort: 15 analogs after dropping 3 b-upper-bound riders. b median 0.92
|
|
90
|
+
# (IQR 0.31). qi median 14,200 bbl/mo at 9,800 ft lateral.
|
|
91
|
+
#
|
|
92
|
+
# Target 42-329-12345 (Wolfcamp A, 9,400 ft):
|
|
93
|
+
# 36 months observed, qi_ratio 1.04 -> in line with cohort
|
|
94
|
+
# Choice: pdp; fit own decline with b=0.9 (cohort median, dropped outliers)
|
|
95
|
+
#
|
|
96
|
+
# Target 42-329-12999 (PERMITTED, 10,200 ft):
|
|
97
|
+
# no history; planned spud 2026-08
|
|
98
|
+
# Choice: pure_analog with cohort curve at target lateral
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### EXECUTE — inline the per-well calls, then run econ
|
|
102
|
+
|
|
103
|
+
No router constants. No `THIN_HISTORY_THRESHOLD`. No `B_FIXED`. Each
|
|
104
|
+
well's strategy is whatever the DECIDE block said.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
forecasts_oil, forecasts_gas = [], []
|
|
108
|
+
for api, choice in CHOICES.items():
|
|
109
|
+
if choice["strategy"] == "pdp":
|
|
110
|
+
forecasts_oil.append(_build_pdp(api, b=choice["b"], stream="oil", ...))
|
|
111
|
+
elif choice["strategy"] == "thin_blend":
|
|
112
|
+
forecasts_oil.append(_build_thin(api, cohort_median_oil, ...))
|
|
113
|
+
else: # pure_analog
|
|
114
|
+
forecasts_oil.append(_build_pure_analog(api, cohort_median_oil, ...))
|
|
115
|
+
# same for gas
|
|
116
|
+
|
|
117
|
+
revenue = compute_gross_revenue(forecasts_oil, forecasts_gas, price_deck=...)
|
|
118
|
+
cashflow = compute_net_cashflow(revenue, interest_type=INTEREST_TYPE, ...)
|
|
119
|
+
npv_dict = npv(cashflow, discount_rates=[0.08, 0.10, 0.12, 0.15])
|
|
120
|
+
|
|
121
|
+
spec = build_minimal_briefing(headline=..., commentary=..., npv=npv_dict, ...)
|
|
122
|
+
persist(spec, persist_url="$persist_url")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Commentary must reference at least one concrete diagnostic you looked at
|
|
126
|
+
(e.g. "cohort of 14 analogs, b median 0.92 with IQR 0.31; target's first
|
|
127
|
+
4 months at 71% of cohort qi suggests below-average completion").
|
|
128
|
+
|
|
129
|
+
## Playbooks
|
|
130
|
+
|
|
131
|
+
`crude_valuation/forecast/examples/` ships annotated playbooks. Today
|
|
132
|
+
only `forecast_mixed.py` is in the new playbook form; the other two are
|
|
133
|
+
still recipe-style and will be migrated.
|
|
134
|
+
|
|
135
|
+
## Diagnostic helpers in detail
|
|
136
|
+
|
|
137
|
+
- `cohort_summary(curves)` — distribution stats per fitted parameter
|
|
138
|
+
(qi, di, b, terminal, lateral). Plus `notes` flagging fits that rode
|
|
139
|
+
the b bound.
|
|
140
|
+
- `fit_quality(curve, months, q)` — R^2, RMSE, bound-riding flags, max
|
|
141
|
+
residual %, early/late signed-% bias windows for a single fit.
|
|
142
|
+
- `target_vs_cohort(cohort, target_months, target_q, target_lateral_ft)` —
|
|
143
|
+
qi_ratio of target to cohort, peak detection, per-month signed-%
|
|
144
|
+
residual vs cohort placed at target lateral.
|
|
145
|
+
|
|
146
|
+
None of these decide anything. You read them and choose.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""crude-valuation — valuation toolkit for the inner valuation_agent.
|
|
2
|
+
|
|
3
|
+
Wraps crude_analyst (the base briefing platform) and adds domain math.
|
|
4
|
+
The agent only ever imports from crude_valuation; crude_analyst is invisible.
|
|
5
|
+
"""
|
|
6
|
+
from crude_analyst import * # noqa: F401, F403
|
|
7
|
+
|
|
8
|
+
# Econ subsystem
|
|
9
|
+
from crude_valuation.econ import ( # noqa: F401
|
|
10
|
+
compute_gross_revenue,
|
|
11
|
+
compute_net_cashflow,
|
|
12
|
+
npv,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Shared utilities
|
|
16
|
+
from crude_valuation.compose import build_minimal_briefing # noqa: F401
|
|
17
|
+
|
|
18
|
+
# Forecast subsystem — flat re-exports for the agent
|
|
19
|
+
from crude_valuation.forecast import ( # noqa: F401
|
|
20
|
+
# Types
|
|
21
|
+
CohortSummary,
|
|
22
|
+
CurveProvenance,
|
|
23
|
+
DeclineCurve,
|
|
24
|
+
FitQuality,
|
|
25
|
+
Forecast,
|
|
26
|
+
ForecastProvenance,
|
|
27
|
+
ParamStats,
|
|
28
|
+
TargetComparison,
|
|
29
|
+
WellMeta,
|
|
30
|
+
# Primitives
|
|
31
|
+
fit_curve,
|
|
32
|
+
percentile_curves,
|
|
33
|
+
blend_curves,
|
|
34
|
+
curve_rate,
|
|
35
|
+
# Diagnostics
|
|
36
|
+
cohort_summary,
|
|
37
|
+
fit_quality,
|
|
38
|
+
target_vs_cohort,
|
|
39
|
+
# Projection + aggregation
|
|
40
|
+
project,
|
|
41
|
+
aggregate,
|
|
42
|
+
# DB helpers
|
|
43
|
+
peek_well,
|
|
44
|
+
load_production,
|
|
45
|
+
load_well_status,
|
|
46
|
+
resolve_asset_list,
|
|
47
|
+
find_analogs,
|
|
48
|
+
# Errors
|
|
49
|
+
AnalogError,
|
|
50
|
+
WellsError,
|
|
51
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Case file parsing for the valuation agent.
|
|
2
|
+
|
|
3
|
+
Validates the JSON contract the MCP tool sends, returns a typed CaseFile
|
|
4
|
+
the rest of the package can read without re-checking shapes.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CaseFileError(ValueError):
|
|
11
|
+
"""Raised when the case file fails schema validation."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CaseFile:
|
|
16
|
+
interest_type: str # "wi" | "minerals"
|
|
17
|
+
interest: dict # {wi_pct, nri_pct} or {decimal}
|
|
18
|
+
asset_list: dict # {well_apis: [...]} XOR {filter_sql: "..."}
|
|
19
|
+
economics_overrides: dict = field(default_factory=dict)
|
|
20
|
+
transcript: list = field(default_factory=list)
|
|
21
|
+
queries_run: list = field(default_factory=list)
|
|
22
|
+
handoff: str = ""
|
|
23
|
+
source_documents: list = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_case_file(body: dict) -> CaseFile:
|
|
27
|
+
"""Validate + parse the case file body. Raises CaseFileError on any failure."""
|
|
28
|
+
if not isinstance(body, dict):
|
|
29
|
+
raise CaseFileError(f"case file must be an object, got {type(body).__name__}")
|
|
30
|
+
|
|
31
|
+
interest_type = body.get("interest_type")
|
|
32
|
+
if interest_type not in ("wi", "minerals"):
|
|
33
|
+
raise CaseFileError(
|
|
34
|
+
f"interest_type must be 'wi' or 'minerals', got {interest_type!r}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
interest = body.get("interest")
|
|
38
|
+
if not isinstance(interest, dict):
|
|
39
|
+
raise CaseFileError("interest must be an object")
|
|
40
|
+
if interest_type == "wi":
|
|
41
|
+
for k in ("wi_pct", "nri_pct"):
|
|
42
|
+
if k not in interest:
|
|
43
|
+
raise CaseFileError(f"interest.{k} is required for interest_type='wi'")
|
|
44
|
+
v = interest[k]
|
|
45
|
+
if not isinstance(v, (int, float)) or not (0.0 <= v <= 1.0):
|
|
46
|
+
raise CaseFileError(f"interest.{k} must be in [0, 1], got {v!r}")
|
|
47
|
+
else: # minerals
|
|
48
|
+
if "decimal" not in interest:
|
|
49
|
+
raise CaseFileError("interest.decimal is required for interest_type='minerals'")
|
|
50
|
+
v = interest["decimal"]
|
|
51
|
+
if not isinstance(v, (int, float)) or not (0.0 <= v <= 1.0):
|
|
52
|
+
raise CaseFileError(f"interest.decimal must be in [0, 1], got {v!r}")
|
|
53
|
+
|
|
54
|
+
asset_list = body.get("asset_list")
|
|
55
|
+
if not isinstance(asset_list, dict):
|
|
56
|
+
raise CaseFileError("asset_list must be an object")
|
|
57
|
+
has_apis = bool(asset_list.get("well_apis"))
|
|
58
|
+
has_sql = bool(asset_list.get("filter_sql"))
|
|
59
|
+
if has_apis and has_sql:
|
|
60
|
+
raise CaseFileError("asset_list must have exactly one of well_apis or filter_sql, not both")
|
|
61
|
+
if not has_apis and not has_sql:
|
|
62
|
+
raise CaseFileError("asset_list must have exactly one of well_apis or filter_sql")
|
|
63
|
+
if has_apis and not all(isinstance(a, str) for a in asset_list["well_apis"]):
|
|
64
|
+
raise CaseFileError("asset_list.well_apis must be a list of strings")
|
|
65
|
+
if has_sql and not isinstance(asset_list["filter_sql"], str):
|
|
66
|
+
raise CaseFileError("asset_list.filter_sql must be a string")
|
|
67
|
+
|
|
68
|
+
handoff = body.get("handoff")
|
|
69
|
+
if not isinstance(handoff, str) or not handoff.strip():
|
|
70
|
+
raise CaseFileError("handoff is required and must be a non-empty string")
|
|
71
|
+
|
|
72
|
+
transcript = body.get("transcript")
|
|
73
|
+
if not isinstance(transcript, list) or len(transcript) == 0:
|
|
74
|
+
raise CaseFileError("transcript is required and must be a non-empty list")
|
|
75
|
+
for i, turn in enumerate(transcript):
|
|
76
|
+
if not isinstance(turn, dict):
|
|
77
|
+
raise CaseFileError(f"transcript[{i}] must be an object")
|
|
78
|
+
if turn.get("role") not in ("user", "assistant"):
|
|
79
|
+
raise CaseFileError(f"transcript[{i}].role must be 'user' or 'assistant'")
|
|
80
|
+
if not isinstance(turn.get("content"), str):
|
|
81
|
+
raise CaseFileError(f"transcript[{i}].content must be a string")
|
|
82
|
+
|
|
83
|
+
queries_run = body.get("queries_run", [])
|
|
84
|
+
if not isinstance(queries_run, list):
|
|
85
|
+
raise CaseFileError("queries_run must be a list")
|
|
86
|
+
|
|
87
|
+
economics_overrides = body.get("economics_overrides", {}) or {}
|
|
88
|
+
if not isinstance(economics_overrides, dict):
|
|
89
|
+
raise CaseFileError("economics_overrides must be an object")
|
|
90
|
+
|
|
91
|
+
source_documents = body.get("source_documents", []) or []
|
|
92
|
+
if not isinstance(source_documents, list):
|
|
93
|
+
raise CaseFileError("source_documents must be a list")
|
|
94
|
+
|
|
95
|
+
return CaseFile(
|
|
96
|
+
interest_type=interest_type,
|
|
97
|
+
interest=interest,
|
|
98
|
+
asset_list=asset_list,
|
|
99
|
+
economics_overrides=economics_overrides,
|
|
100
|
+
transcript=transcript,
|
|
101
|
+
queries_run=queries_run,
|
|
102
|
+
handoff=handoff.strip(),
|
|
103
|
+
source_documents=source_documents,
|
|
104
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Build a minimal Slice-1 briefing spec from valuation results.
|
|
2
|
+
|
|
3
|
+
Slice 1 is PV-only and commentary-only:
|
|
4
|
+
|
|
5
|
+
- PV-only — no IRR, no payout, no MOIC, no sensitivity. Those land in a later
|
|
6
|
+
slice; this slice ships discount-rate NPV values and the methodology footer.
|
|
7
|
+
- Commentary-only — no callout widget. The base `callout` is designed to render
|
|
8
|
+
a value from a SQL row, not a Python-side literal; using it for a static
|
|
9
|
+
headline NPV would require a crude_analyst widget change we're deferring.
|
|
10
|
+
Instead the headline NPV lives in the commentary text body.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
from crude_analyst import briefing, section, commentary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fmt_money(x: float) -> str:
|
|
17
|
+
"""Render dollar values as $X.YM, $XK, etc. Negative shown as -$X.YM."""
|
|
18
|
+
if not math.isfinite(x):
|
|
19
|
+
return "n/a"
|
|
20
|
+
sign = "-" if x < 0 else ""
|
|
21
|
+
a = abs(x)
|
|
22
|
+
if a >= 1e9:
|
|
23
|
+
return f"{sign}${a/1e9:.2f}B"
|
|
24
|
+
if a >= 1e6:
|
|
25
|
+
return f"{sign}${a/1e6:.2f}M"
|
|
26
|
+
if a >= 1e3:
|
|
27
|
+
return f"{sign}${a/1e3:.1f}K"
|
|
28
|
+
return f"{sign}${a:.0f}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _headline_rate(npv_by_rate: dict[float, float]) -> float:
|
|
32
|
+
"""Pick the headline discount rate — PV10 if present, else the lowest rate."""
|
|
33
|
+
if 0.10 in npv_by_rate:
|
|
34
|
+
return 0.10
|
|
35
|
+
return min(npv_by_rate.keys())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_minimal_briefing(
|
|
39
|
+
*,
|
|
40
|
+
npv_by_rate: dict[float, float],
|
|
41
|
+
n_wells: int,
|
|
42
|
+
interest_type: str,
|
|
43
|
+
methodology_notes: str,
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""Assemble the Slice 1 briefing: one commentary widget with PV results."""
|
|
46
|
+
headline_rate = _headline_rate(npv_by_rate)
|
|
47
|
+
headline_npv = npv_by_rate[headline_rate]
|
|
48
|
+
headline_label = f"PV{int(headline_rate*100)}"
|
|
49
|
+
interest_label = "minerals" if interest_type == "minerals" else "working-interest"
|
|
50
|
+
|
|
51
|
+
lines = [
|
|
52
|
+
f"{interest_label.capitalize()} valuation across {n_wells} producing well(s).",
|
|
53
|
+
f"{headline_label}: {_fmt_money(headline_npv)}.",
|
|
54
|
+
]
|
|
55
|
+
if len(npv_by_rate) > 1:
|
|
56
|
+
other = ", ".join(
|
|
57
|
+
f"PV{int(r*100)}: {_fmt_money(v)}"
|
|
58
|
+
for r, v in sorted(npv_by_rate.items())
|
|
59
|
+
if r != headline_rate
|
|
60
|
+
)
|
|
61
|
+
lines.append(f"Other rates — {other}.")
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append(f"Methodology: {methodology_notes}")
|
|
64
|
+
|
|
65
|
+
spec = briefing(
|
|
66
|
+
headline=f"{headline_label} {_fmt_money(headline_npv)} on {n_wells} {interest_label} well(s).",
|
|
67
|
+
tldr=f"{n_wells}-well {interest_type} valuation; headline {headline_label} = {_fmt_money(headline_npv)}.",
|
|
68
|
+
sections=[
|
|
69
|
+
section(
|
|
70
|
+
label="01 · Result",
|
|
71
|
+
layout="full-width",
|
|
72
|
+
widgets=[commentary(text="\n".join(lines))],
|
|
73
|
+
),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
spec["headline_npv"] = {
|
|
77
|
+
str(round(rate * 100)): value for rate, value in npv_by_rate.items()
|
|
78
|
+
}
|
|
79
|
+
return spec
|