Asteria Investment Office — Institutional Portfolio-Risk Solver Skill
A transferable SOP for solving Asteria Investment Office portfolio-risk tasks. Three workflows feed
the test set: (A) energy/fixed-income credit trade strategy, (B) international equity correlation
review, (C) cross-asset active allocation view updates. Some test tasks combine B+C.
0. Golden rules (read first)
- The remote environment is the book of record. Base URL
<remote-env-url>. Every
input/payloads/*.json is intake context and may be stale (old worksheet marks, stale quantity
boards, prior-week shortlists, old desk notes). When a payload conflicts with the environment,
prefer the environment. The answer field data_precedence / your reasoning must reflect this.
- Fetch fresh. Do not trust locally-cached payload numbers for holdings, quantities, or marks.
Always pull
/api/portfolios/<id> for current holdings and /api/instruments/bonds,
/api/issuers for security master. Stale-quantity boards in particular disagree with the
portfolio service; use the portfolio service quantities.
- No correlations are stored. Compute every correlation yourself from
/api/index-levels
(monthly simple returns, Pearson). Never invent or reuse a correlation.
- Precision is declared per-field in
answer_template.json. Respect it exactly:
notional_usd_m / quantity_usd_m / post_trade_watchlist_exposure_usd_m → 1 decimal.
- All
post_trade_metrics, risk_metrics percents/years → 2 decimals.
correlation and allocation signal_score → 3 decimals.
- Round half-up at the end; do not round intermediate inputs.
- Output exactly one JSON object matching the template's required keys, enums, and ordering.
Sort trade lists as the template dictates (ascending
instrument_id; SELL-before-BUY then
instrument_id). Pair index ids alphabetically inside each pair.
- No judge, no hidden gold. Derive every value from environment + template. Do not assume a
"correct" pre-decided answer; the environment + policy thresholds define correctness.
1. Endpoints that matter
All GET, all on <remote-env-url>:
/api/catalog — every portfolio/bond/issuer/index/policy/opportunity-set id. Start here to confirm ids.
/api/portfolios — summaries (id, objective, strategy, constraint_policy_id, MV, holding count, as_of_date).
/api/portfolios/<id> — objective, constraints, current holdings (instrument_id, quantity_usd_m, sleeve, notes), MV. This is authoritative for holdings & quantities.
/api/instruments/bonds (filters: ?candidate=true, ?rating_bucket=HY) — bond universe. Per bond: candidate, energy_linked, rating, rating_bucket (IG/HY), modified_duration_years, yield_to_maturity_pct, coupon_pct, spread_bps, sector, subsector, issuer_id, maturity, recommended_theme_tags.
/api/issuers — per issuer: sector, subsector, rating_bucket, watchlist (bool), credit_outlook, research_tags. Join to bonds via issuer_id.
/api/policies — one object with sub-policies: credit_default, credit_risk_reduction, correlation, allocation_mapping, multi_asset, multi_asset_risk. Each portfolio's constraint_policy_id names which applies.
/api/market/energy — commodity signals (oil/gas/LNG/refining/renewables) with score, direction, signal_id, pitch_themes, stale_data_warning. Use for energy-desk theme/sales positioning.
/api/indices — index metadata (region, frequency, level window).
/api/index-levels — {index_id: [{date, level}, ...]} monthly. Source for all correlations.
/api/allocation/opportunity-sets — taxonomy mapping opportunity_set → asset_class (Equities/Duration/Credit/Currency) + display_order.
/api/allocation/prior-views — records {quarter, previous_quarter, opportunity_set, view, conviction}. Holds the PRIOR-period view (see §6 for the exact selection rule).
/api/macro-signals — {quarter, opportunity_set, score, rationale_code, drivers}. Source for new allocation views.
2. Policy reference (environment facts, re-fetch to confirm)
credit_default / credit_risk_reduction (POL_CREDIT_DEFAULT / POL_CREDIT_RISK_REDUCTION):
duration_band_years: [3.0, 5.0], max_hy_allocation_pct: 20.0,
issuer_concentration_limit_pct: 12.0, subsector_min_count_for_diversified: 2,
target_hy_reduction_pct (0 for credit_default; 4.0 for credit_risk_reduction — a required
percentage-point reduction).
correlation (POL_CORRELATION_DEFAULT): correlation_high_threshold: 0.8,
correlation_low_threshold: 0.2, window start/end align with index level window.
allocation_mapping (POL_ALLOCATION_MAPPING):
view_score_thresholds: OW_min: 0.35, UW_max: -0.35, neutral between [-0.35, 0.35].
conviction_thresholds: HIGH_abs_min: 0.7, MEDIUM_abs_min: 0.35, LOW_abs_below: 0.35.
view_rank: UW=-1, N=0, OW=1.
multi_asset (POL_MULTI_ASSET_DEFAULT) uses_allocation_mapping/uses_correlation_default/uses_credit_default: true.
multi_asset_risk (POL_MULTI_ASSET_RISK) uses_correlation_default/uses_credit_risk_reduction: true, escalates on two_or_more_material_exceptions.
3. Precision & ordering conventions (universal)
- Trade lists: sort as template states. Energy trade package = ascending
instrument_id. Lumen
rotation trades = SELL before BUY, then ascending instrument_id within each action.
notional_usd_m / quantity_usd_m: 1 decimal (e.g. 4.0, not 4).
total_market_value_usd_m, hy_allocation_pct, weighted_modified_duration_years,
weighted_yield_to_maturity_pct, post_trade_hy_allocation_pct, post_trade_duration_years,
hy_reduction_pct_points: 2 decimals.
post_trade_watchlist_exposure_usd_m: 1 decimal.
- Correlation,
signal_score: 3 decimals.
as_of_date: use the portfolio summary as_of_date (currently 2026-05-29) unless the task says otherwise.
- Pair ids: alphabetical within the pair; sleeve/lists ordered ascending unless an
item_order is given.
WORKFLOW A — Energy / fixed-income credit trade strategy
Covers: BUY/SELL bond tickets under credit constraints; post-trade metrics; sales positioning;
data-precedence (train_001 BUY-only energy income; train_004 HY/watchlist risk-reduction rotation).
A1. Load current state (order matters)
GET /api/portfolios/<portfolio_id> → holdings (instrument_id → quantity_usd_m), MV, constraints.
GET /api/instruments/bonds → join each holding to its bond master (rating_bucket, modified_duration_years, yield_to_maturity_pct, subsector, issuer_id, energy_linked).
GET /api/issuers → join issuer_id → watchlist, credit_outlook.
GET /api/market/energy → signal scores + pitch themes (for energy-desk tasks).
GET /api/policies → the portfolio's constraint_policy_id sub-policy.
Re-read holdings from the portfolio service even if a stale snapshot / exception board is in the
payload — those boards routinely disagree by a few $m (e.g. a watchlist HY bond shown as 10.0 on
the stale board is 12.0 in the service). The service wins.
A2. Post-trade metric formulas (market-value-weighted)
For the post-trade book (existing holdings ± trades):
total_market_value_usd_m = Σ quantity_usd_m (after trades).
hy_allocation_pct = (Σ quantity where rating_bucket=="HY") / total_market_value_usd_m * 100.
weighted_modified_duration_years = Σ(quantity * modified_duration_years) / total_market_value_usd_m.
weighted_yield_to_maturity_pct = Σ(quantity * yield_to_maturity_pct) / total_market_value_usd_m.
Weight by quantity (=$m par/market value here; the service gives one figure). Round outputs to 2 dp.
For rotation tasks: hy_reduction_pct_points = pre_trade_hy_pct − post_trade_hy_pct (2 dp);
post_trade_watchlist_exposure_usd_m = Σ quantity of holdings whose issuer is watchlist (1 dp).
A3. Constraint pass/fail
hy_cap_pass: post-trade hy_allocation_pct <= max_hy_allocation_pct (20.0).
duration_band_pass: duration_band_years[0] <= weighted_modified_duration_years <= [1] (3.0–5.0).
selected_issuer_diversification_pass: the SELECTED (new) tickets' issuers are distinct. (Issuer
concentration limit 12% also exists; check a selected buy isn't an existing-issuer overload.)
selected_subsector_diversification_pass: the selected tickets span >= subsector_min_count_for_diversified (2) distinct subsectors.
watchlist_avoidance_pass: no selected BUY whose issuer watchlist==true. For rotation tasks
also report watchlist_exposure_cleared (post-trade watchlist $m == 0) and buys_avoid_watchlist.
target_hy_reduction_met (risk-reduction policy only): hy_reduction_pct_points >= target_hy_reduction_pct (4.0).
A4. BUY-only energy selection (train_001-style)
Inputs: exactly N BUY tickets (usually 2) for a fixed total notional, split evenly; energy-linked;
improve carry; keep inside constraints; client-facing income pitch.
Selection filter for candidates:
candidate==true AND energy_linked==true AND not already a holding.
- Watchlist filter: issuer
watchlist==false (credit committee is sensitive to headline
watchlist carry — drop Driftwood/Pacific Refining/Juniper even though their YTM is highest).
- Duration-band filter on the post-trade book, but a good heuristic is to drop candidates whose
own
modified_duration_years is outside [3.0, 5.0] — long-dated LNG/oil (dur ~5.8–6.7) and
very short HY (dur <3.0) are duration-ineligible distractors; including them risks breaching the
band. Prefer candidates whose own duration sits inside the band.
- Prefer the desk's preferred themes (LNG exporters / gas demand): match
recommended_theme_tags
(LNG_EXPORTS, GAS_DEMAND) and the strongest market/energy signal (LNG export pull is the
most positive energy signal).
- Improve carry: among eligible, prefer higher YTM. One IG anchor (LNG/gas, IG) + one non-watchlist
HY carry bond is the canonical "improve carry while staying diversified" pair; verify the HY
addition keeps
hy_allocation_pct well under the 20% cap and that the two picks are distinct
issuers and distinct subsectors.
Output sales_positioning: target_segment from the request's client_context
(multi_asset_income etc.); theme from the energy pitch themes — pick the one matching the
anchor (e.g. lng_export_tailwind when the IG anchor is an LNG exporter). data_precedence =
current_environment_over_stale_payload whenever a stale worksheet mark differs from the service
(MV, HY%, duration, or quantity) — which is the normal case.
A5. HY/watchlist risk-reduction rotation (train_004-style)
Goal: lower HY, remove avoidable watchlist risk, keep duration in the CIO band, fund IG candidates.
- Identify watchlist holdings (issuer
watchlist==true) — sell them (fully) to clear watchlist.
- The portfolio is usually far over the 20% HY cap. Compute pre-trade HY%. To clear the cap with MV
held roughly constant, sell at least
(pre_hy_pct − 20)% * total_mv of HY. Selling only the
watchlist bond may not reach the cap — sell additional non-watchlist HY "pressure points" too.
- Keep at least the higher-rated HY for carry if "preserve carry" is an objective; don't strip all
HY unless the desk asks.
- Fund IG candidates from the desk shortlist /
?candidate=true IG universe. Exclude any candidate
whose issuer is watchlist (avoid_new_watchlist_buy). Verify each buy's own duration sits inside
[3.0, 5.0]; spread buys across distinct issuers (issuer conc 12%).
- Preserve duration: selling short-duration HY (dur ~2.8–3.4) raises portfolio duration; buying
longer IG (dur ~4.0–4.8) raises it further. Re-check
weighted_modified_duration_years <= 5.0.
If it would breach 5.0, tilt buys toward shorter IG / sell some longer IG.
risk_note_code: pick the dominant story — watchlist_concentration (watchlist was the issue),
hy_cap_pressure (HY still over cap after rotation), duration_preservation (rotation held
duration), carry_tradeoff (gave up carry to reduce risk), no_action.
Pitfalls: the stale exception board's quantities are NOT the service quantities — re-fetch. The
desk shortlist often includes a watchlist HY bond (high carry, "risk team concerned about issuer
status") as a trap; exclude it. Duration-ineligible IG candidates (dur >5.0) appear in shortlists
as "duration ballast" but breach the band — check, don't trust the label.
WORKFLOW B — International equity correlation review
Covers: pair correlations across an index universe, highest/lowest pairs, China/Asia dependence
flags, diversification candidates, sleeve actions (train_002; also the correlation half of train_005).
B1. Compute correlations
GET /api/index-levels (and/or /api/index-levels/<id>). Each index has monthly {date, level}.
- For each index, sort by date, compute simple monthly returns
r_t = level_t / level_{t-1} − 1
across the review window (level_start_date … level_end_date). 12 monthly levels ⇒ 11 returns.
- Pearson correlation per pair:
cov(r_i, r_j) / (std(r_i)*std(r_j)) over the shared return dates.
- Round to 3 decimals.
return_observations = number of returns (e.g. 11).
Use the window declared in the request payload's review_window (start/end dates) — these match the
index metadata level_start_date/level_end_date.
B2. Extremes & pairs
highest_positive: the pair with the maximum correlation. lowest: the pair with the minimum
correlation (can be negative). pair_id / pair = the two index ids sorted alphabetically.
- For combined B+C tasks (train_005):
correlation_summary has exactly two items in order
[highest_concentration, best_diversifier]. highest_concentration = the highest absolute
correlation pair (the pair that moves together most = concentration risk). best_diversifier =
the lowest (most negative) correlation pair (best diversification). Both pairs sorted
alphabetically internally.
B3. Concentration flags (POL_CORRELATION_DEFAULT)
high_threshold_breached: any pair correlation >= correlation_high_threshold (0.8). Usually true
for regional equity universes — many pairs exceed 0.8.
china_asia_dependence_flag: set when the dedicated China sleeve and the Asia-Pacific-ex-Japan
sleeve are both highly correlated with EM/World (Asia beta overlap). Check IDX_CHINA and
IDX_AC_ASIA_PAC_EX_JP correlations against IDX_EM / IDX_WORLD — if multiple exceed 0.8, flag true.
primary_code:
CHINA_ASIA_DEPENDENCE when China/AsiaPac/EM inter-correlations dominate the high pairs.
GLOBAL_DEVELOPED_OVERLAP when EAFE/World/ACWI developed-overlap pairs dominate instead.
NO_MATERIAL_CONCENTRATION only when no pair breaches 0.8.
B4. Diversification candidates & sleeve actions
diversification_candidates: indices with low (ideally negative) correlation to the concentrated
China/Asia cluster. Typical allowed set: IDX_EM_EX_CHINA, IDX_INDIA, IDX_LATAM — list all
that qualify, ascending alphabetical. LatAm's negative correlation to China/Asia makes it the
strongest diversifier.
sleeve_actions (length per template, ascending by sleeve): the two most actionable moves.
Typical pattern: trim/rotate the concentrated China sleeve toward IDX_EM_EX_CHINA (keep EM
beta, drop single-country concentration), and add the best diversifier (IDX_LATAM).
action ∈ {trim, add, hold, hedge, monitor, rotate}; target_index_id ∈ the sleeve's allowed set.
- For combined B+C tasks:
target_sleeve_actions lists one action per requested sleeve (e.g. EM,
India, LatAm, USD) in the template's item_order. Map each sleeve's allocation view to an action:
UW+DOWN → trim; OW+UP → add; OW+UNCHANGED-high-conviction → add or hold; N → hold/monitor;
a currency sleeve used as defensive offset → hedge.
B5. Correlation pitfalls
- Use simple returns (level ratio − 1), NOT log returns, unless the template says otherwise.
- Ensure both indices share the same monthly dates; align by date before correlating.
- Don't round returns before correlating — round only the final correlation.
- A negative "lowest" pair is normal; don't force it positive.
WORKFLOW C — Cross-asset active allocation view updates
Covers: active allocation views (view/change/conviction/rationale_code) per opportunity set, risk
overlay, lineage, concentration flag, rebalance trigger, next-step enums (train_003; the allocation
half of train_005).
C1. The view-derivation pipeline (this is the core algorithm)
For a refresh with target_quarter T and prior_quarter P (e.g. T=Q2_2026, P=Q1_2026):
- New view from
/api/macro-signals where quarter==T, using allocation_mapping thresholds:
score >= 0.35 → OW; score <= -0.35 → UW; else N.
- Conviction from
|score|:
|score| >= 0.7 → HIGH; >= 0.35 → MEDIUM; < 0.35 → LOW.
- rationale_code = the macro signal's own
rationale_code (e.g. EUROPE_RECOVERY,
JAPAN_POLICY_RISK, CHINA_DEPENDENCE, INDIA_OFFSET, LATAM_DIVERSIFIER,
DURATION_SUPPORT, HY_VALUATION_RISK, DOLLAR_DEFENSIVE, CREDIT_SPREAD_RISK,
GROWTH_IMPROVES, RATE_CUT_SUPPORT, NEUTRAL_BALANCE).
- prior_view from
/api/allocation/prior-views. Selection rule (critical): pick the
records where quarter == T (these carry previous_quarter == P); their view (and
conviction) IS the prior-period (P) view. Do NOT look for a quarter == P batch — it does not
exist. Do NOT treat the prior-views view as the new/target view; the new view always comes from
the macro signal via step 1.
- change = compare
view_rank of new vs prior: UP if new>old, DOWN if new<old, else
UNCHANGED. (view_rank: UW=-1, N=0, OW=1.)
- asset_class from
/api/allocation/opportunity-sets (Europe→Equities, U.S. Treasuries→Duration,
Corporate High Yield→Credit, USD→Currency, etc.).
Sanity check: the prior-views batch where quarter == T reflects the incoming (P) view; a later
batch where quarter == T+1 would reflect the just-derived (T) view and should match your
signal-derived T views for most (not all) opportunity sets — committee judgment can override an
occasional name, so trust your signal derivation for the T view, and trust prior-views for the P view.
C2. Output ordering & required rows
allocation_views ordered by the request payload's focus_opportunity_sets order (NOT alphabetical,
NOT display_order). Include exactly the requested opportunity sets (train_003 = 8; train_005 = 4).
- Each row:
opportunity_set, asset_class, view, change, conviction, rationale_code.
- train_005 also wants
prior_view and signal_score (raw macro score, 3 dp) per row.
C3. Risk overlay (train_003)
risk_overlay: overlay_code, primary_action, rationale_codes (list, business-priority order,
highest priority first).
- Decide from the dominant risk in the view set. When HY is UW on
HY_VALUATION_RISK and duration
(U.S. Treasuries) is OW on DURATION_SUPPORT, the overlay is DURATION_QUALITY_TILT with
primary_action: tilt_to_duration_quality — rotate out of credit beta into duration/quality.
When credit spread risk dominates with no duration offset, use CREDIT_RISK_REDUCTION /
trim_credit_beta. When currency defensiveness dominates (USD/CHF defensive), use
CURRENCY_DEFENSIVE_HEDGE / add_currency_hedge. If views net to neutral, NO_OVERLAY /
hold_policy_weights.
rationale_codes priority order: lead with the most actionable risk-reduction code (the one
driving the overlay), then supporting codes. Deduplicate.
policy_id (train_003) = POL_ALLOCATION_MAPPING (the policy whose thresholds produce the views).
task_id = the template's required_value (e.g. train_003). as_of_date = portfolio/env as_of.
target_quarter/prior_quarter = the request's quarters (template holds required_value).
C4. Combined correlation+allocation committee JSON (train_005-style)
Top-level: portfolio_id, as_of_date, review_quarter (the allocation quarter, e.g. Q2_2026),
correlation_summary (§B2), target_sleeve_actions (§B4, in item_order), allocation_views
(§C1, with prior_view+signal_score), rebalance_trigger, portfolio_risk_concentration_flag,
next_step.
rebalance_trigger: choose from {correlation_cap_breach, hy_cap_pressure, duration_drift,
watchlist_concentration, committee_review}. When the concentration pair breaches the 0.8
correlation cap and drives the review, use correlation_cap_breach. When it's a routine committee
refresh with no hard breach, committee_review.
portfolio_risk_concentration_flag: true when the highest-concentration pair breaches the 0.8
threshold OR a dedicated China sleeve creates single-country beta — generally true for these sleeves.
next_step: approve_rotation when a clear risk-reducing rotation is proposed and no hard
constraint is breached; approve_with_monitoring when concentration is flagged but managed;
defer_pending_risk_review when signals conflict materially; reject_constraint_breach only when
a hard policy cap is violated by the proposal.
C5. Allocation pitfalls
- The prior-view selection rule (§C1.4) is the #1 error source. Re-read it. The
view field in
prior-views is the PRIOR (incoming) view for the refresh tagged by quarter==T, not the new T view.
- Don't copy conviction from prior-views — derive the NEW conviction from
|score|.
change is about view RANK (UW<N<OW), not score magnitude. OW→OW is UNCHANGED even if conviction rises.
rationale_code must come from the macro signal, never invented. If two opportunity sets share a
rationale code (e.g. Europe and EUR both EUROPE_RECOVERY), that's fine.
signal_score in output = the raw macro score (3 dp), not the derived view.
- A stale local note (e.g. "USD overweight as defensive offset") describes a PRIOR posture, not the
current view — refresh from signals; the USD score is often negative (defensive bid fading).
4. Reusable computation snippets
Pearson over monthly levels (Python):
def returns(levels): # levels: list of (date, level) sorted by date
r = []
for i in range(1, len(levels)):
r.append(levels[i][1] / levels[i-1][1] - 1.0)
return r
def pearson(x, y):
n = len(x); mx = sum(x)/n; my = sum(y)/n
cov = sum((x[i]-mx)*(y[i]-my) for i in range(n))
return cov / ((sum((xi-mx)**2 for xi in x)**0.5) * (sum((yi-my)**2 for yi in y)**0.5))
View derivation:
def view_of(s): return "OW" if s>=0.35 else ("UW" if s<=-0.35 else "N")
def conv_of(s):
a=abs(s); return "HIGH" if a>=0.7 else ("MEDIUM" if a>=0.35 else "LOW")
RANK={"UW":-1,"N":0,"OW":1}
def change_of(new,old):
return "UP" if RANK[new]>RANK[old] else ("DOWN" if RANK[new]<RANK[old] else "UNCHANGED")
# prior_view: prior_views record where record["quarter"]==target_quarter (previous_quarter==prior)
Post-trade metrics:
tot = sum(q for q in holdings.values())
hy = sum(q for id,q in holdings.items() if bond[id]["rating_bucket"]=="HY")
wdur= sum(q*bond[id]["modified_duration_years"] for id,q in holdings.items())/tot
wytm= sum(q*bond[id]["yield_to_maturity_pct"] for id,q in holdings.items())/tot
hy_pct = hy/tot*100
5. Distillation checklist (run before emitting JSON)
1---2name: self-attempt-03-143description: Asteria Investment Office — Institutional Portfolio-Risk Solver Skill4---5# Asteria Investment Office — Institutional Portfolio-Risk Solver Skill67A transferable SOP for solving Asteria Investment Office portfolio-risk tasks. Three workflows feed8the test set: (A) energy/fixed-income credit trade strategy, (B) international equity correlation9review, (C) cross-asset active allocation view updates. Some test tasks combine B+C.1011## 0. Golden rules (read first)12131. **The remote environment is the book of record.** Base URL `<remote-env-url>`. Every14 `input/payloads/*.json` is intake context and may be stale (old worksheet marks, stale quantity15 boards, prior-week shortlists, old desk notes). When a payload conflicts with the environment,16 prefer the environment. The answer field `data_precedence` / your reasoning must reflect this.172. **Fetch fresh.** Do not trust locally-cached payload numbers for holdings, quantities, or marks.18 Always pull `/api/portfolios/<id>` for current holdings and `/api/instruments/bonds`,19 `/api/issuers` for security master. Stale-quantity boards in particular disagree with the20 portfolio service; use the portfolio service quantities.213. **No correlations are stored.** Compute every correlation yourself from `/api/index-levels`22 (monthly simple returns, Pearson). Never invent or reuse a correlation.234. **Precision is declared per-field in `answer_template.json`.** Respect it exactly:24 - `notional_usd_m` / `quantity_usd_m` / `post_trade_watchlist_exposure_usd_m` → 1 decimal.25 - All `post_trade_metrics`, `risk_metrics` percents/years → 2 decimals.26 - `correlation` and allocation `signal_score` → 3 decimals.27 - Round half-up at the end; do not round intermediate inputs.285. **Output exactly one JSON object** matching the template's required keys, enums, and ordering.29 Sort trade lists as the template dictates (ascending `instrument_id`; SELL-before-BUY then30 `instrument_id`). Pair index ids alphabetically inside each pair.316. **No judge, no hidden gold.** Derive every value from environment + template. Do not assume a32 "correct" pre-decided answer; the environment + policy thresholds define correctness.3334## 1. Endpoints that matter3536All GET, all on `<remote-env-url>`:3738- `/api/catalog` — every portfolio/bond/issuer/index/policy/opportunity-set id. Start here to confirm ids.39- `/api/portfolios` — summaries (id, objective, strategy, `constraint_policy_id`, MV, holding count, **`as_of_date`**).40- `/api/portfolios/<id>` — objective, **constraints**, **current holdings** (instrument_id, quantity_usd_m, sleeve, notes), MV. This is authoritative for holdings & quantities.41- `/api/instruments/bonds` (filters: `?candidate=true`, `?rating_bucket=HY`) — bond universe. Per bond: `candidate`, `energy_linked`, `rating`, `rating_bucket` (IG/HY), `modified_duration_years`, `yield_to_maturity_pct`, `coupon_pct`, `spread_bps`, `sector`, `subsector`, `issuer_id`, `maturity`, `recommended_theme_tags`.42- `/api/issuers` — per issuer: `sector`, `subsector`, `rating_bucket`, **`watchlist`** (bool), `credit_outlook`, `research_tags`. Join to bonds via `issuer_id`.43- `/api/policies` — one object with sub-policies: `credit_default`, `credit_risk_reduction`, `correlation`, `allocation_mapping`, `multi_asset`, `multi_asset_risk`. Each portfolio's `constraint_policy_id` names which applies.44- `/api/market/energy` — commodity signals (oil/gas/LNG/refining/renewables) with `score`, `direction`, `signal_id`, `pitch_themes`, `stale_data_warning`. Use for energy-desk theme/sales positioning.45- `/api/indices` — index metadata (region, frequency, level window).46- `/api/index-levels` — `{index_id: [{date, level}, ...]}` monthly. Source for all correlations.47- `/api/allocation/opportunity-sets` — taxonomy mapping `opportunity_set` → `asset_class` (Equities/Duration/Credit/Currency) + `display_order`.48- `/api/allocation/prior-views` — records `{quarter, previous_quarter, opportunity_set, view, conviction}`. **Holds the PRIOR-period view** (see §6 for the exact selection rule).49- `/api/macro-signals` — `{quarter, opportunity_set, score, rationale_code, drivers}`. Source for new allocation views.5051## 2. Policy reference (environment facts, re-fetch to confirm)5253- `credit_default` / `credit_risk_reduction` (POL_CREDIT_DEFAULT / POL_CREDIT_RISK_REDUCTION):54 `duration_band_years: [3.0, 5.0]`, `max_hy_allocation_pct: 20.0`,55 `issuer_concentration_limit_pct: 12.0`, `subsector_min_count_for_diversified: 2`,56 `target_hy_reduction_pct` (0 for credit_default; 4.0 for credit_risk_reduction — a required57 percentage-point reduction).58- `correlation` (POL_CORRELATION_DEFAULT): `correlation_high_threshold: 0.8`,59 `correlation_low_threshold: 0.2`, window start/end align with index level window.60- `allocation_mapping` (POL_ALLOCATION_MAPPING):61 - `view_score_thresholds`: `OW_min: 0.35`, `UW_max: -0.35`, neutral between `[-0.35, 0.35]`.62 - `conviction_thresholds`: `HIGH_abs_min: 0.7`, `MEDIUM_abs_min: 0.35`, `LOW_abs_below: 0.35`.63 - `view_rank`: `UW=-1, N=0, OW=1`.64- `multi_asset` (POL_MULTI_ASSET_DEFAULT) `uses_allocation_mapping/uses_correlation_default/uses_credit_default: true`.65- `multi_asset_risk` (POL_MULTI_ASSET_RISK) `uses_correlation_default/uses_credit_risk_reduction: true`, escalates on `two_or_more_material_exceptions`.6667## 3. Precision & ordering conventions (universal)6869- Trade lists: sort as template states. Energy trade package = ascending `instrument_id`. Lumen70 rotation trades = SELL before BUY, then ascending `instrument_id` within each action.71- `notional_usd_m` / `quantity_usd_m`: 1 decimal (e.g. `4.0`, not `4`).72- `total_market_value_usd_m`, `hy_allocation_pct`, `weighted_modified_duration_years`,73 `weighted_yield_to_maturity_pct`, `post_trade_hy_allocation_pct`, `post_trade_duration_years`,74 `hy_reduction_pct_points`: 2 decimals.75- `post_trade_watchlist_exposure_usd_m`: 1 decimal.76- Correlation, `signal_score`: 3 decimals.77- `as_of_date`: use the portfolio summary `as_of_date` (currently `2026-05-29`) unless the task says otherwise.78- Pair ids: alphabetical within the pair; sleeve/lists ordered ascending unless an `item_order` is given.7980---8182## WORKFLOW A — Energy / fixed-income credit trade strategy8384Covers: BUY/SELL bond tickets under credit constraints; post-trade metrics; sales positioning;85data-precedence (train_001 BUY-only energy income; train_004 HY/watchlist risk-reduction rotation).8687### A1. Load current state (order matters)881. `GET /api/portfolios/<portfolio_id>` → holdings (instrument_id → quantity_usd_m), MV, constraints.892. `GET /api/instruments/bonds` → join each holding to its bond master (rating_bucket, modified_duration_years, yield_to_maturity_pct, subsector, issuer_id, energy_linked).903. `GET /api/issuers` → join `issuer_id` → `watchlist`, `credit_outlook`.914. `GET /api/market/energy` → signal scores + pitch themes (for energy-desk tasks).925. `GET /api/policies` → the portfolio's `constraint_policy_id` sub-policy.9394Re-read holdings from the portfolio service even if a stale snapshot / exception board is in the95payload — those boards routinely disagree by a few $m (e.g. a watchlist HY bond shown as 10.0 on96the stale board is 12.0 in the service). The service wins.9798### A2. Post-trade metric formulas (market-value-weighted)99For the post-trade book (existing holdings ± trades):100101- `total_market_value_usd_m = Σ quantity_usd_m` (after trades).102- `hy_allocation_pct = (Σ quantity where rating_bucket=="HY") / total_market_value_usd_m * 100`.103- `weighted_modified_duration_years = Σ(quantity * modified_duration_years) / total_market_value_usd_m`.104- `weighted_yield_to_maturity_pct = Σ(quantity * yield_to_maturity_pct) / total_market_value_usd_m`.105106Weight by quantity (=$m par/market value here; the service gives one figure). Round outputs to 2 dp.107For rotation tasks: `hy_reduction_pct_points = pre_trade_hy_pct − post_trade_hy_pct` (2 dp);108`post_trade_watchlist_exposure_usd_m = Σ quantity of holdings whose issuer is watchlist` (1 dp).109110### A3. Constraint pass/fail111- `hy_cap_pass`: post-trade `hy_allocation_pct <= max_hy_allocation_pct` (20.0).112- `duration_band_pass`: `duration_band_years[0] <= weighted_modified_duration_years <= [1]` (3.0–5.0).113- `selected_issuer_diversification_pass`: the SELECTED (new) tickets' issuers are distinct. (Issuer114 concentration limit 12% also exists; check a selected buy isn't an existing-issuer overload.)115- `selected_subsector_diversification_pass`: the selected tickets span `>= subsector_min_count_for_diversified` (2) distinct subsectors.116- `watchlist_avoidance_pass`: no selected BUY whose issuer `watchlist==true`. For rotation tasks117 also report `watchlist_exposure_cleared` (post-trade watchlist $m == 0) and `buys_avoid_watchlist`.118- `target_hy_reduction_met` (risk-reduction policy only): `hy_reduction_pct_points >= target_hy_reduction_pct` (4.0).119120### A4. BUY-only energy selection (train_001-style)121Inputs: exactly N BUY tickets (usually 2) for a fixed total notional, split evenly; energy-linked;122improve carry; keep inside constraints; client-facing income pitch.123124Selection filter for candidates:1251. `candidate==true` AND `energy_linked==true` AND not already a holding.1262. **Watchlist filter:** issuer `watchlist==false` (credit committee is sensitive to headline127 watchlist carry — drop Driftwood/Pacific Refining/Juniper even though their YTM is highest).1283. **Duration-band filter on the post-trade book, but a good heuristic is to drop candidates whose129 own `modified_duration_years` is outside `[3.0, 5.0]`** — long-dated LNG/oil (dur ~5.8–6.7) and130 very short HY (dur <3.0) are duration-ineligible distractors; including them risks breaching the131 band. Prefer candidates whose own duration sits inside the band.1324. Prefer the desk's preferred themes (LNG exporters / gas demand): match `recommended_theme_tags`133 (`LNG_EXPORTS`, `GAS_DEMAND`) and the strongest `market/energy` signal (LNG export pull is the134 most positive energy signal).1355. Improve carry: among eligible, prefer higher YTM. One IG anchor (LNG/gas, IG) + one non-watchlist136 HY carry bond is the canonical "improve carry while staying diversified" pair; verify the HY137 addition keeps `hy_allocation_pct` well under the 20% cap and that the two picks are distinct138 issuers and distinct subsectors.139140Output `sales_positioning`: `target_segment` from the request's `client_context`141(`multi_asset_income` etc.); `theme` from the energy pitch themes — pick the one matching the142anchor (e.g. `lng_export_tailwind` when the IG anchor is an LNG exporter). `data_precedence` =143`current_environment_over_stale_payload` whenever a stale worksheet mark differs from the service144(MV, HY%, duration, or quantity) — which is the normal case.145146### A5. HY/watchlist risk-reduction rotation (train_004-style)147Goal: lower HY, remove avoidable watchlist risk, keep duration in the CIO band, fund IG candidates.1481491. Identify watchlist holdings (issuer `watchlist==true`) — sell them (fully) to clear watchlist.1502. The portfolio is usually far over the 20% HY cap. Compute pre-trade HY%. To clear the cap with MV151 held roughly constant, sell at least `(pre_hy_pct − 20)% * total_mv` of HY. Selling only the152 watchlist bond may not reach the cap — sell additional non-watchlist HY "pressure points" too.1533. Keep at least the higher-rated HY for carry if "preserve carry" is an objective; don't strip all154 HY unless the desk asks.1554. Fund IG candidates from the desk shortlist / `?candidate=true` IG universe. Exclude any candidate156 whose issuer is watchlist (`avoid_new_watchlist_buy`). Verify each buy's own duration sits inside157 `[3.0, 5.0]`; spread buys across distinct issuers (issuer conc 12%).1585. Preserve duration: selling short-duration HY (dur ~2.8–3.4) raises portfolio duration; buying159 longer IG (dur ~4.0–4.8) raises it further. Re-check `weighted_modified_duration_years <= 5.0`.160 If it would breach 5.0, tilt buys toward shorter IG / sell some longer IG.1616. `risk_note_code`: pick the dominant story — `watchlist_concentration` (watchlist was the issue),162 `hy_cap_pressure` (HY still over cap after rotation), `duration_preservation` (rotation held163 duration), `carry_tradeoff` (gave up carry to reduce risk), `no_action`.164165Pitfalls: the stale exception board's quantities are NOT the service quantities — re-fetch. The166desk shortlist often includes a watchlist HY bond (high carry, "risk team concerned about issuer167status") as a trap; exclude it. Duration-ineligible IG candidates (dur >5.0) appear in shortlists168as "duration ballast" but breach the band — check, don't trust the label.169170---171172## WORKFLOW B — International equity correlation review173174Covers: pair correlations across an index universe, highest/lowest pairs, China/Asia dependence175flags, diversification candidates, sleeve actions (train_002; also the correlation half of train_005).176177### B1. Compute correlations1781. `GET /api/index-levels` (and/or `/api/index-levels/<id>`). Each index has monthly `{date, level}`.1792. For each index, sort by date, compute **simple monthly returns** `r_t = level_t / level_{t-1} − 1`180 across the review window (level_start_date … level_end_date). 12 monthly levels ⇒ 11 returns.1813. Pearson correlation per pair: `cov(r_i, r_j) / (std(r_i)*std(r_j))` over the shared return dates.1824. Round to 3 decimals. `return_observations` = number of returns (e.g. 11).183184Use the window declared in the request payload's `review_window` (start/end dates) — these match the185index metadata `level_start_date`/`level_end_date`.186187### B2. Extremes & pairs188- `highest_positive`: the pair with the maximum correlation. `lowest`: the pair with the minimum189 correlation (can be negative). `pair_id` / `pair` = the two index ids sorted alphabetically.190- For combined B+C tasks (train_005): `correlation_summary` has exactly two items in order191 [`highest_concentration`, `best_diversifier`]. `highest_concentration` = the **highest** absolute192 correlation pair (the pair that moves together most = concentration risk). `best_diversifier` =193 the **lowest** (most negative) correlation pair (best diversification). Both pairs sorted194 alphabetically internally.195196### B3. Concentration flags (POL_CORRELATION_DEFAULT)197- `high_threshold_breached`: any pair correlation `>= correlation_high_threshold` (0.8). Usually true198 for regional equity universes — many pairs exceed 0.8.199- `china_asia_dependence_flag`: set when the dedicated China sleeve and the Asia-Pacific-ex-Japan200 sleeve are both highly correlated with EM/World (Asia beta overlap). Check IDX_CHINA and201 IDX_AC_ASIA_PAC_EX_JP correlations against IDX_EM / IDX_WORLD — if multiple exceed 0.8, flag true.202- `primary_code`:203 - `CHINA_ASIA_DEPENDENCE` when China/AsiaPac/EM inter-correlations dominate the high pairs.204 - `GLOBAL_DEVELOPED_OVERLAP` when EAFE/World/ACWI developed-overlap pairs dominate instead.205 - `NO_MATERIAL_CONCENTRATION` only when no pair breaches 0.8.206207### B4. Diversification candidates & sleeve actions208- `diversification_candidates`: indices with low (ideally negative) correlation to the concentrated209 China/Asia cluster. Typical allowed set: `IDX_EM_EX_CHINA`, `IDX_INDIA`, `IDX_LATAM` — list all210 that qualify, ascending alphabetical. LatAm's negative correlation to China/Asia makes it the211 strongest diversifier.212- `sleeve_actions` (length per template, ascending by sleeve): the two most actionable moves.213 Typical pattern: **trim/rotate the concentrated China sleeve** toward `IDX_EM_EX_CHINA` (keep EM214 beta, drop single-country concentration), and **add the best diversifier** (`IDX_LATAM`).215 `action` ∈ {trim, add, hold, hedge, monitor, rotate}; `target_index_id` ∈ the sleeve's allowed set.216- For combined B+C tasks: `target_sleeve_actions` lists one action per requested sleeve (e.g. EM,217 India, LatAm, USD) in the template's `item_order`. Map each sleeve's allocation view to an action:218 UW+DOWN → `trim`; OW+UP → `add`; OW+UNCHANGED-high-conviction → `add` or `hold`; N → `hold`/`monitor`;219 a currency sleeve used as defensive offset → `hedge`.220221### B5. Correlation pitfalls222- Use **simple** returns (level ratio − 1), NOT log returns, unless the template says otherwise.223- Ensure both indices share the same monthly dates; align by date before correlating.224- Don't round returns before correlating — round only the final correlation.225- A negative "lowest" pair is normal; don't force it positive.226227---228229## WORKFLOW C — Cross-asset active allocation view updates230231Covers: active allocation views (view/change/conviction/rationale_code) per opportunity set, risk232overlay, lineage, concentration flag, rebalance trigger, next-step enums (train_003; the allocation233half of train_005).234235### C1. The view-derivation pipeline (this is the core algorithm)236For a refresh with `target_quarter` T and `prior_quarter` P (e.g. T=Q2_2026, P=Q1_2026):2372381. **New view** from `/api/macro-signals` where `quarter==T`, using `allocation_mapping` thresholds:239 - `score >= 0.35` → `OW`; `score <= -0.35` → `UW`; else `N`.2402. **Conviction** from `|score|`:241 - `|score| >= 0.7` → `HIGH`; `>= 0.35` → `MEDIUM`; `< 0.35` → `LOW`.2423. **rationale_code** = the macro signal's own `rationale_code` (e.g. `EUROPE_RECOVERY`,243 `JAPAN_POLICY_RISK`, `CHINA_DEPENDENCE`, `INDIA_OFFSET`, `LATAM_DIVERSIFIER`,244 `DURATION_SUPPORT`, `HY_VALUATION_RISK`, `DOLLAR_DEFENSIVE`, `CREDIT_SPREAD_RISK`,245 `GROWTH_IMPROVES`, `RATE_CUT_SUPPORT`, `NEUTRAL_BALANCE`).2464. **prior_view** from `/api/allocation/prior-views`. **Selection rule (critical):** pick the247 records where `quarter == T` (these carry `previous_quarter == P`); their `view` (and248 `conviction`) IS the prior-period (P) view. Do NOT look for a `quarter == P` batch — it does not249 exist. Do NOT treat the prior-views `view` as the new/target view; the new view always comes from250 the macro signal via step 1.2515. **change** = compare `view_rank` of new vs prior: `UP` if new>old, `DOWN` if new<old, else252 `UNCHANGED`. (`view_rank`: UW=-1, N=0, OW=1.)2536. **asset_class** from `/api/allocation/opportunity-sets` (Europe→Equities, U.S. Treasuries→Duration,254 Corporate High Yield→Credit, USD→Currency, etc.).255256Sanity check: the prior-views batch where `quarter == T` reflects the incoming (P) view; a later257batch where `quarter == T+1` would reflect the just-derived (T) view and should match your258signal-derived T views for most (not all) opportunity sets — committee judgment can override an259occasional name, so trust your signal derivation for the T view, and trust prior-views for the P view.260261### C2. Output ordering & required rows262- `allocation_views` ordered by the request payload's `focus_opportunity_sets` order (NOT alphabetical,263 NOT display_order). Include exactly the requested opportunity sets (train_003 = 8; train_005 = 4).264- Each row: `opportunity_set`, `asset_class`, `view`, `change`, `conviction`, `rationale_code`.265- train_005 also wants `prior_view` and `signal_score` (raw macro score, 3 dp) per row.266267### C3. Risk overlay (train_003)268`risk_overlay`: `overlay_code`, `primary_action`, `rationale_codes` (list, **business-priority order,269highest priority first**).270- Decide from the dominant risk in the view set. When HY is UW on `HY_VALUATION_RISK` and duration271 (U.S. Treasuries) is OW on `DURATION_SUPPORT`, the overlay is `DURATION_QUALITY_TILT` with272 `primary_action: tilt_to_duration_quality` — rotate out of credit beta into duration/quality.273 When credit spread risk dominates with no duration offset, use `CREDIT_RISK_REDUCTION` /274 `trim_credit_beta`. When currency defensiveness dominates (USD/CHF defensive), use275 `CURRENCY_DEFENSIVE_HEDGE` / `add_currency_hedge`. If views net to neutral, `NO_OVERLAY` /276 `hold_policy_weights`.277- `rationale_codes` priority order: lead with the most actionable risk-reduction code (the one278 driving the overlay), then supporting codes. Deduplicate.279280`policy_id` (train_003) = `POL_ALLOCATION_MAPPING` (the policy whose thresholds produce the views).281`task_id` = the template's `required_value` (e.g. `train_003`). `as_of_date` = portfolio/env as_of.282`target_quarter`/`prior_quarter` = the request's quarters (template holds `required_value`).283284### C4. Combined correlation+allocation committee JSON (train_005-style)285Top-level: `portfolio_id`, `as_of_date`, `review_quarter` (the allocation quarter, e.g. Q2_2026),286`correlation_summary` (§B2), `target_sleeve_actions` (§B4, in `item_order`), `allocation_views`287(§C1, with `prior_view`+`signal_score`), `rebalance_trigger`, `portfolio_risk_concentration_flag`,288`next_step`.289290- `rebalance_trigger`: choose from {`correlation_cap_breach`, `hy_cap_pressure`, `duration_drift`,291 `watchlist_concentration`, `committee_review`}. When the concentration pair breaches the 0.8292 correlation cap and drives the review, use `correlation_cap_breach`. When it's a routine committee293 refresh with no hard breach, `committee_review`.294- `portfolio_risk_concentration_flag`: true when the highest-concentration pair breaches the 0.8295 threshold OR a dedicated China sleeve creates single-country beta — generally true for these sleeves.296- `next_step`: `approve_rotation` when a clear risk-reducing rotation is proposed and no hard297 constraint is breached; `approve_with_monitoring` when concentration is flagged but managed;298 `defer_pending_risk_review` when signals conflict materially; `reject_constraint_breach` only when299 a hard policy cap is violated by the proposal.300301### C5. Allocation pitfalls302- **The prior-view selection rule (§C1.4) is the #1 error source.** Re-read it. The `view` field in303 prior-views is the PRIOR (incoming) view for the refresh tagged by `quarter==T`, not the new T view.304- Don't copy conviction from prior-views — derive the NEW conviction from `|score|`.305- `change` is about view RANK (UW<N<OW), not score magnitude. OW→OW is `UNCHANGED` even if conviction rises.306- `rationale_code` must come from the macro signal, never invented. If two opportunity sets share a307 rationale code (e.g. Europe and EUR both `EUROPE_RECOVERY`), that's fine.308- `signal_score` in output = the raw macro score (3 dp), not the derived view.309- A stale local note (e.g. "USD overweight as defensive offset") describes a PRIOR posture, not the310 current view — refresh from signals; the USD score is often negative (defensive bid fading).311312---313314## 4. Reusable computation snippets315316Pearson over monthly levels (Python):317```python318def returns(levels): # levels: list of (date, level) sorted by date319 r = []320 for i in range(1, len(levels)):321 r.append(levels[i][1] / levels[i-1][1] - 1.0)322 return r323def pearson(x, y):324 n = len(x); mx = sum(x)/n; my = sum(y)/n325 cov = sum((x[i]-mx)*(y[i]-my) for i in range(n))326 return cov / ((sum((xi-mx)**2 for xi in x)**0.5) * (sum((yi-my)**2 for yi in y)**0.5))327```328329View derivation:330```python331def view_of(s): return "OW" if s>=0.35 else ("UW" if s<=-0.35 else "N")332def conv_of(s):333 a=abs(s); return "HIGH" if a>=0.7 else ("MEDIUM" if a>=0.35 else "LOW")334RANK={"UW":-1,"N":0,"OW":1}335def change_of(new,old):336 return "UP" if RANK[new]>RANK[old] else ("DOWN" if RANK[new]<RANK[old] else "UNCHANGED")337# prior_view: prior_views record where record["quarter"]==target_quarter (previous_quarter==prior)338```339340Post-trade metrics:341```python342tot = sum(q for q in holdings.values())343hy = sum(q for id,q in holdings.items() if bond[id]["rating_bucket"]=="HY")344wdur= sum(q*bond[id]["modified_duration_years"] for id,q in holdings.items())/tot345wytm= sum(q*bond[id]["yield_to_maturity_pct"] for id,q in holdings.items())/tot346hy_pct = hy/tot*100347```348349## 5. Distillation checklist (run before emitting JSON)350- [ ] Did I fetch holdings from `/api/portfolios/<id>` (not the stale payload board)?351- [ ] Are watchlist issuers excluded from BUYS (and sold in rotations)?352- [ ] Are duration-ineligible candidates (own mod_dur outside [3,5]) excluded?353- [ ] Post-trade HY%, duration, YTM computed market-value-weighted and rounded to 2 dp?354- [ ] Correlations computed from monthly simple returns, Pearson, 3 dp, pairs alphabetical?355- [ ] Allocation views derived from macro signals via the 0.35 / 0.7 thresholds; prior_view from356 prior-views where `quarter==target`; change from view rank?357- [ ] Trade/list ordering matches the template (ascending instrument_id; SELL-before-BUY)?358- [ ] `data_precedence` reflects env-over-stale when any payload figure disagreed with the service?359- [ ] All enums pulled from the template's allowed_values, no typos?360- [ ] One JSON object only, no narrative outside it (unless the prompt asks otherwise)?