fix: handle multi-component investment recommendations (CD ladders)
When adding a multi-stage investment strategy (e.g. CD ladder) from AI recommendations to a board planning scenario, all component investments are now created as separate rows instead of collapsing into a single investment. The AI prompt now instructs inclusion of a components array, the backend loops through components to create individual scenario investments, and the frontend passes and displays component details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,37 @@ export class BoardPlanningService {
|
||||
|
||||
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
||||
await this.getScenarioRow(scenarioId);
|
||||
|
||||
// If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component
|
||||
const components = dto.components as any[] | undefined;
|
||||
if (components && Array.isArray(components) && components.length > 0) {
|
||||
const results: any[] = [];
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
const comp = components[i];
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||
principal, interest_rate, term_months, institution, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
scenarioId, dto.sourceRecommendationId || null,
|
||||
comp.label || `${dto.title || 'AI Recommendation'} - Part ${i + 1}`,
|
||||
comp.investment_type || dto.investmentType || null,
|
||||
dto.fundType || 'reserve',
|
||||
comp.amount || 0, comp.rate || null,
|
||||
comp.term_months || null, comp.bank_name || dto.bankName || null,
|
||||
dto.rationale || dto.notes || null,
|
||||
i,
|
||||
],
|
||||
);
|
||||
results.push(rows[0]);
|
||||
}
|
||||
await this.invalidateProjectionCache(scenarioId);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Single investment (no components)
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, source_recommendation_id, label, investment_type, fund_type,
|
||||
|
||||
@@ -38,6 +38,15 @@ export interface MarketRate {
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
export interface RecommendationComponent {
|
||||
label: string;
|
||||
amount: number;
|
||||
term_months: number;
|
||||
rate: number;
|
||||
bank_name?: string;
|
||||
investment_type?: string;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
@@ -50,6 +59,7 @@ export interface Recommendation {
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
components?: RecommendationComponent[];
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
@@ -904,13 +914,28 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
|
||||
"suggested_term": "12 months",
|
||||
"suggested_rate": 4.50,
|
||||
"bank_name": "Bank name from market rates (if applicable)",
|
||||
"rationale": "Financial reasoning for why this makes sense"
|
||||
"rationale": "Financial reasoning for why this makes sense",
|
||||
"components": [
|
||||
{
|
||||
"label": "Component label (e.g. '6-Month CD at Marcus')",
|
||||
"amount": 6600.00,
|
||||
"term_months": 6,
|
||||
"rate": 4.05,
|
||||
"bank_name": "Marcus",
|
||||
"investment_type": "cd"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||
}
|
||||
|
||||
IMPORTANT ABOUT COMPONENTS:
|
||||
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
|
||||
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
|
||||
- For simple single-investment recommendations, omit the "components" field entirely.
|
||||
|
||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||
|
||||
// Build the data context for the user prompt
|
||||
|
||||
@@ -85,6 +85,15 @@ interface MarketRatesResponse {
|
||||
high_yield_savings: MarketRate[];
|
||||
}
|
||||
|
||||
interface RecommendationComponent {
|
||||
label: string;
|
||||
amount: number;
|
||||
term_months: number;
|
||||
rate: number;
|
||||
bank_name?: string;
|
||||
investment_type?: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
type: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
@@ -97,6 +106,7 @@ interface Recommendation {
|
||||
suggested_rate?: number;
|
||||
bank_name?: string;
|
||||
rationale: string;
|
||||
components?: RecommendationComponent[];
|
||||
}
|
||||
|
||||
interface AIResponse {
|
||||
@@ -392,6 +402,7 @@ export function InvestmentPlanningPage() {
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
components: rec.components || undefined,
|
||||
});
|
||||
return scenarioId;
|
||||
},
|
||||
@@ -421,6 +432,7 @@ export function InvestmentPlanningPage() {
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
components: rec.components || undefined,
|
||||
});
|
||||
return scenario.id;
|
||||
},
|
||||
@@ -865,6 +877,16 @@ export function InvestmentPlanningPage() {
|
||||
{selectedRec.suggested_amount != null && (
|
||||
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
||||
)}
|
||||
{selectedRec.components && selectedRec.components.length > 0 && (
|
||||
<Stack gap={2} mt={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>{selectedRec.components.length} investments will be created:</Text>
|
||||
{selectedRec.components.map((c, i) => (
|
||||
<Text key={i} size="xs" c="dimmed">
|
||||
{c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo)
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user