This guide continues from Build a strategy and covers the refinement loop:
- Start with a rough objective.
- Enhance the objective with
POST /enhance-objective.
- Create and submit a strategy.
- Poll until processing is complete (
status == 100).
- Review holdings.
- Look up a company id from a symbol with
POST /helper/companies/search.
- Fetch reasoning and evidence for a company.
- Craft exclusion phrases grounded in the holdings, then
PATCH them onto the strategy.
- Resubmit and repeat. Optionally generate an inverse objective with
POST /reverse-objective and run the same loop.
Build a strategy shows this loop as curl one-liners. Here it is scripted in Python and extended with the refine, exclude, and resubmit cycle.
The examples use Python and the requests library.
All requests target the REST API base URL https://api.noonum.ai/v2 and authenticate with an
Authorization: Bearer <token> header, where the token is your Noonum API key.
See Authentication for details.
Public-catalog strategies are read-only. You can read them, but you can only refine strategies you own.
Each time you submit a changed draft, Noonum freezes the new objective and exclusions into a fresh
active version and reads default to that version. Watch active_version_id and draft_version_id
to track which version is serving and whether your draft has unsaved edits.
See Objective versioning for the full model.
Setup
from time import sleep
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.noonum.ai/v2"
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
Iterate a strategy until you’re happy
1. Start with an objective, then enhance it
name = "European defense sector"
raw_objective = "Invest in companies that would benefit from increased spend in the European defense sector."
enhance_resp = requests.post(
f"{BASE_URL}/enhance-objective",
json={"objective": raw_objective},
headers=headers,
)
enhance_resp.raise_for_status()
enhanced = enhance_resp.json()
objective = enhanced["enhancedObjective"]
print("Enhanced objective:", objective)
print("Improvements summary:", enhanced.get("improvementsSummary"))
2. Create a strategy from the enhanced objective
create_resp = requests.post(
f"{BASE_URL}/strategies",
json={"name": name, "objective": objective},
headers=headers,
)
create_resp.raise_for_status()
strategy = create_resp.json()
strategy_id = strategy["id"]
print("Strategy id:", strategy_id)
3. Submit the strategy for processing
submit_resp = requests.post(f"{BASE_URL}/strategies/{strategy_id}/submit", headers=headers)
submit_resp.raise_for_status()
4. Poll until processing is complete
See Build a strategy for how submit-and-poll works (status == 100
means processing finished).
while True:
strategy_resp = requests.get(f"{BASE_URL}/strategies/{strategy_id}", headers=headers)
strategy_resp.raise_for_status()
status = strategy_resp.json()["status"]
print("status:", status)
if status == 100:
break
sleep(10)
5. Review companies in the strategy
companies_resp = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/companies",
headers=headers,
)
companies_resp.raise_for_status()
companies = companies_resp.json()
print("Companies returned:", len(companies))
print("First company:", companies[0])
Each company entry includes an id for fetching its reasoning and evidence.
6. Look up a company id from a symbol
If you have a symbol in mind (e.g. "ABC") but don’t know the Noonum company id,
use POST /helper/companies/search.
# Use a known-in-strategy symbol to start (then replace with any symbol/ISIN/FIGI/name fragment).
symbol_query = companies[0].get("symbol") or "ABC"
search_resp = requests.post(
f"{BASE_URL}/helper/companies/search",
json={"query": symbol_query},
headers=headers,
)
search_resp.raise_for_status()
search_results = search_resp.json()["results"]
company = search_results[0]
company_id = company["id"]
print("Matched company:", company["name"], company_id)
7. Fetch evidence for a company in your strategy
This endpoint explains why a company is or isn’t in your strategy results:
- If the company fits the strategy, you’ll see
isIncluded: true, a human-readable
reasoning, and summaries containing the evidence excerpts.
- If the company does not fit (or there isn’t enough supporting evidence), you’ll still
get a response with
isIncluded: false, a short “why not” message in reasoning, and
typically an empty summaries list.
evidence_resp = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/companies/{company_id}",
headers=headers,
)
evidence_resp.raise_for_status()
company_evidence = evidence_resp.json()
print("isIncluded:", company_evidence.get("isIncluded"))
print("reasoning:", company_evidence.get("reasoning"))
print("summaries (count):", len(company_evidence.get("summaries", [])))
To pull reasoning and evidence for all companies at once:
all_evidences_resp = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/evidences",
headers=headers,
)
all_evidences_resp.raise_for_status()
all_evidences = all_evidences_resp.json()["evidences"]
print("Companies with evidences:", len(all_evidences))
8. Craft exclusion phrases grounded in the holdings
An exclusion is a short phrase describing a category of companies you want removed.
After you add exclusions and resubmit, the matching companies drop out and new companies
may surface to replace them. Add an exclusion to remove a specific company you don’t want
even if it’s otherwise relevant, or to remove a type of company such as a sector or business
activity.
Write each phrase as a short description of what a company is (2–6 words, no negation,
no “exclude” prefix, no company names). See Build a strategy for
the full exclusion rules. Ground the phrase in the holdings: look at the inclusion reasoning
of the companies you want to remove, and describe the shared pattern.
POST /exclusion-phrases turns a company’s evidence and reasoning into suggested phrases.
You can also write your own. Pass:
- your strategy
objective
- the
companyId you’re reviewing
- the company’s
inclusionReason (use the reasoning field you just fetched)
inclusion_reason = company_evidence.get("reasoning") or ""
exclusion_resp = requests.post(
f"{BASE_URL}/exclusion-phrases",
json={
"objective": objective,
"companyId": company_id,
"inclusionReason": inclusion_reason,
},
headers=headers,
)
exclusion_resp.raise_for_status()
suggestions = exclusion_resp.json()["phrases"]
print("Suggested phrases:")
for item in suggestions:
print("-", item["phrase"], "=>", item["reason"])
Now choose which phrases to apply as exclusions. Start with 2–4; you can always add more.
# Example: pick the first 2 suggested phrases (or choose any subset)
exclusions = [suggestions[0]["phrase"], suggestions[1]["phrase"]]
print("Chosen exclusions:", exclusions)
9. Update the strategy with your exclusions, then resubmit
patch_resp = requests.patch(
f"{BASE_URL}/strategies/{strategy_id}",
json={"exclusions": exclusions},
headers=headers,
)
patch_resp.raise_for_status()
updated_strategy = patch_resp.json()
print("Updated exclusions:", updated_strategy.get("exclusions"))
Editing objective or exclusions changes the draft, so draft_version_id becomes null
until you resubmit. The next submit freezes the new draft into a fresh active version.
See Objective versioning for details.
Resubmit and repeat the review loop.
requests.post(f"{BASE_URL}/strategies/{strategy_id}/submit", headers=headers).raise_for_status()
while True:
strategy_resp = requests.get(f"{BASE_URL}/strategies/{strategy_id}", headers=headers)
strategy_resp.raise_for_status()
if strategy_resp.json()["status"] == 100:
break
sleep(10)
companies_resp = requests.get(f"{BASE_URL}/strategies/{strategy_id}/companies", headers=headers)
companies_resp.raise_for_status()
companies = companies_resp.json()
print("New companies returned:", len(companies))
After resubmission, check that your exclusions didn’t remove companies you wanted to keep,
then keep iterating.
Create an inverse strategy objective
Use POST /reverse-objective to generate an “inverse” strategy, then repeat the loop above.
reverse_resp = requests.post(
f"{BASE_URL}/reverse-objective",
json={"objective": objective},
headers=headers,
)
reverse_resp.raise_for_status()
reverse = reverse_resp.json()
inverse_objective = reverse["reverseObjective"]
inverse_name = reverse.get("reverseName") or f"Inverse of {name}"
print("Inverse name:", inverse_name)
print("Inverse objective:", inverse_objective)
From here, repeat the same steps:
POST /strategies with inverse_name and inverse_objective
POST /strategies/{strategyId}/submit
- poll
GET /strategies/{strategyId} until status == 100
- review companies and evidence
- craft exclusions and
PATCH /strategies/{strategyId} with the updated exclusions
- resubmit and repeat
Generate historical results
There is no /historical-data endpoint in v2. To produce results for a past date, submit a run
with a past as_of_date, poll that (version, date) cell to completion, then read its companies
with asOfDate. Historical runs are rate-limited; an already-in-flight run for the same date
returns 423.
1. Submit historical runs for specific month-end dates
Submit once per date against the active version. You can omit version_id (it defaults to the
current draft’s version), but pinning it to the active version is unambiguous.
active_version_id = requests.get(
f"{BASE_URL}/strategies/{strategy_id}", headers=headers
).json()["active_version_id"]
dates = ["2025-07-31", "2025-06-30"]
for d in dates:
r = requests.post(
f"{BASE_URL}/strategies/{strategy_id}/submit",
json={"as_of_date": d, "version_id": active_version_id},
headers=headers,
)
if r.status_code == 201:
print(f"{d}: accepted")
elif r.status_code == 423:
print(f"{d}: already in flight (treat as accepted)")
elif r.status_code == 429:
body = r.json()
print(f"{d}: rate limited — {body['available']} credits left, "
f"{body['requested']} requested")
elif r.status_code == 400:
print(f"{d}: rejected (future date)")
else:
r.raise_for_status()
2. Poll a historical run to completion
Poll the (version, date) cell via GET /strategies/{strategyId}/versions/{versionId} with asOfDate.
def wait_for_cell(strategy_id, version_id, as_of_date, timeout_s=1800):
waited = 0
while waited < timeout_s:
r = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/versions/{version_id}",
params={"asOfDate": as_of_date},
headers=headers,
)
if r.status_code == 404:
# No run yet for this cell — keep waiting (or it was never submitted).
sleep(10); waited += 10; continue
r.raise_for_status()
status = r.json()["status"]
if status == 100:
return True
if status in (-1, -2):
return False
sleep(10); waited += 10
return False
wait_for_cell(strategy_id, active_version_id, "2025-06-30")
3. List the dates that are ready
available_dates_resp = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/available-dates",
headers=headers, # add params={"versionId": active_version_id} to pin a version
)
available_dates_resp.raise_for_status()
available_dates = available_dates_resp.json()["available_dates"]
print("Available dates:", available_dates)
4. Read historical results for one date
Use the companies endpoint with asOfDate. The response shape matches the live results.
date = available_dates[0]
historical_resp = requests.get(
f"{BASE_URL}/strategies/{strategy_id}/companies",
headers=headers,
params={"asOfDate": date}, # add "versionId": ... to pin a version
)
historical_resp.raise_for_status()
historical_companies = historical_resp.json()
print(f"{date}: {len(historical_companies)} companies")
print("First historical row:", historical_companies[0])
Where to go next
After refinement, request a factsheet with POST /strategies/{strategyId}/factsheet, then
GET /strategies/{strategyId}/factsheet — it returns a 302 redirect to the PDF when ready,
or a 200 status body (generating / not_requested) while it is still being produced. You
can also pass create_factsheet: true on the original submit. (The MCP server tool
get_strategy_factsheet drives the same flow.)