EXTENSION for TreeOS
investor
Investment portfolio tracker. Log buys and sells in natural language, track holdings with cost basis, monitor gains and losses, review allocation. Each holding is a node with shares, entry price, and current price. Say what you bought or sold and the tree updates. Watchlist for targets. The AI reflects on concentration risk, unrealized losses, and allocation balance. Cross-domain: knows your financial health, how investments relate to your whole life. Type 'be' for a guided check-in on your portfolio.
v1.0.1 by TreeOS Site 0 downloads 9 files 838 lines 28.9 KB published 38d ago
treeos ext install investor
View changelog

Manifest

Provides

  • 1 CLI commands

Requires

  • services: hooks, llm, metadata
  • models: Node, Note

Optional

  • extensions: transactions, solana, channels, browser-bridge, html-rendering, treeos-base
SHA256: 9f698fecda6dde540bc36726fd16472a31518dd16847ae3371d3a8d0074dd83c

CLI Commands

CommandMethodDescription
invest [message...]POSTInvestments. Log trades, check portfolio, review.

Hooks

Listens To

  • enrichContext

Source Code

1/**
2 * Investor Core
3 *
4 * Track holdings, log trades, monitor portfolio.
5 * The tree is the portfolio.
6 */
7
8import log from "../../seed/log.js";
9import { setNodeMode } from "../../seed/modes/registry.js";
10
11let _Node = null;
12let _Note = null;
13let _metadata = null;
14
15export function configure({ Node, Note, metadata }) {
16  _Node = Node;
17  _Note = Note;
18  _metadata = metadata;
19}
20
21const ROLES = {
22  LOG: "log",
23  PORTFOLIO: "portfolio",
24  WATCHLIST: "watchlist",
25  HISTORY: "history",
26};
27
28export { ROLES };
29
30// -- Scaffold --
31
32export async function scaffold(rootId, userId) {
33  if (!_Node) throw new Error("Investor core not configured");
34  const { createNode } = await import("../../seed/tree/treeManagement.js");
35
36  const logNode = await createNode({ name: "Log", parentId: rootId, userId });
37  const portfolioNode = await createNode({ name: "Portfolio", parentId: rootId, userId });
38  const watchlistNode = await createNode({ name: "Watchlist", parentId: rootId, userId });
39  const historyNode = await createNode({ name: "History", parentId: rootId, userId });
40
41  const tags = [
42    [logNode, ROLES.LOG],
43    [portfolioNode, ROLES.PORTFOLIO],
44    [watchlistNode, ROLES.WATCHLIST],
45    [historyNode, ROLES.HISTORY],
46  ];
47
48  for (const [node, role] of tags) {
49    await _metadata.setExtMeta(node, "investor", { role });
50  }
51
52  await setNodeMode(rootId, "respond", "tree:investor-coach");
53  await setNodeMode(logNode._id, "respond", "tree:investor-log");
54
55  const root = await _Node.findById(rootId);
56  if (root) {
57    await _metadata.setExtMeta(root, "investor", {
58      initialized: true,
59      setupPhase: "complete",
60    });
61  }
62
63  const ids = {};
64  for (const [node, role] of tags) ids[role] = String(node._id);
65
66  log.info("Investor", `Scaffolded under ${rootId}`);
67  return ids;
68}
69
70// -- Find nodes --
71
72export async function findInvestorNodes(rootId) {
73  if (!_Node) return null;
74  const children = await _Node.find({ parent: rootId }).select("_id name metadata").lean();
75  const result = {};
76  for (const child of children) {
77    const meta = child.metadata instanceof Map
78      ? child.metadata.get("investor")
79      : child.metadata?.investor;
80    if (meta?.role) result[meta.role] = { id: String(child._id), name: child.name };
81  }
82  return result;
83}
84
85export async function isInitialized(rootId) {
86  if (!_Node) return false;
87  const root = await _Node.findById(rootId).select("metadata").lean();
88  if (!root) return false;
89  const meta = root.metadata instanceof Map
90    ? root.metadata.get("investor")
91    : root.metadata?.investor;
92  return !!meta?.initialized;
93}
94
95export async function getSetupPhase(rootId) {
96  if (!_Node) return null;
97  const root = await _Node.findById(rootId).select("metadata").lean();
98  if (!root) return null;
99  const meta = root.metadata instanceof Map
100    ? root.metadata.get("investor")
101    : root.metadata?.investor;
102  return meta?.setupPhase || (meta?.initialized ? "complete" : null);
103}
104
105export async function completeSetup(rootId) {
106  const root = await _Node.findById(rootId);
107  if (!root) return;
108  const existing = _metadata.getExtMeta(root, "investor") || {};
109  await _metadata.setExtMeta(root, "investor", { ...existing, setupPhase: "complete" });
110}
111
112// -- Holdings --
113
114export async function getHoldings(rootId) {
115  const nodes = await findInvestorNodes(rootId);
116  if (!nodes?.portfolio) return [];
117
118  const children = await _Node.find({ parent: nodes.portfolio.id })
119    .select("_id name metadata").lean();
120
121  return children.map(c => {
122    const meta = c.metadata instanceof Map
123      ? c.metadata.get("investor")
124      : c.metadata?.investor;
125    const values = c.metadata instanceof Map
126      ? c.metadata.get("values")
127      : c.metadata?.values;
128    const shares = meta?.shares || 0;
129    const entryPrice = meta?.entryPrice || 0;
130    const currentPrice = meta?.currentPrice || entryPrice;
131    const value = shares * currentPrice;
132    const cost = shares * entryPrice;
133    const gain = value - cost;
134    const gainPercent = cost > 0 ? ((gain / cost) * 100) : 0;
135    return {
136      id: String(c._id),
137      name: c.name,
138      ticker: meta?.ticker || c.name,
139      assetType: meta?.assetType || "stock",
140      shares,
141      entryPrice,
142      currentPrice,
143      value,
144      gain,
145      gainPercent,
146    };
147  });
148}
149
150// -- Watchlist --
151
152export async function getWatchlist(rootId) {
153  const nodes = await findInvestorNodes(rootId);
154  if (!nodes?.watchlist) return [];
155
156  const children = await _Node.find({ parent: nodes.watchlist.id })
157    .select("_id name metadata").lean();
158
159  return children.map(c => {
160    const meta = c.metadata instanceof Map
161      ? c.metadata.get("investor")
162      : c.metadata?.investor;
163    return {
164      id: String(c._id),
165      name: c.name,
166      ticker: meta?.ticker || c.name,
167      targetPrice: meta?.targetPrice || null,
168      stopLoss: meta?.stopLoss || null,
169      notes: meta?.notes || null,
170    };
171  });
172}
173
174// -- Portfolio Summary --
175
176export async function getPortfolioSummary(rootId) {
177  const holdings = await getHoldings(rootId);
178
179  const totalValue = holdings.reduce((sum, h) => sum + h.value, 0);
180  const totalCost = holdings.reduce((sum, h) => sum + (h.shares * h.entryPrice), 0);
181  const totalGain = totalValue - totalCost;
182  const totalGainPercent = totalCost > 0 ? ((totalGain / totalCost) * 100) : 0;
183
184  const allocation = holdings.map(h => ({
185    ticker: h.ticker,
186    name: h.name,
187    value: h.value,
188    percent: totalValue > 0 ? ((h.value / totalValue) * 100) : 0,
189    gain: h.gain,
190    gainPercent: h.gainPercent,
191  }));
192
193  return {
194    holdings,
195    totalValue,
196    totalCost,
197    totalGain,
198    totalGainPercent,
199    allocation,
200  };
201}
202
1/**
2 * No data work needed. The AI handles everything via modes.
3 * Returns null so the orchestrator routes to the default mode.
4 */
5
6export async function handleMessage() {
7  return null;
8}
9
1import express from "express";
2import { sendError, ERR } from "../../seed/protocol.js";
3import urlAuth from "../html-rendering/urlAuth.js";
4import { htmlOnly } from "../html-rendering/htmlHelpers.js";
5import Node from "../../seed/models/node.js";
6import { isInitialized, getPortfolioSummary, getWatchlist } from "./core.js";
7import { renderInvestorDashboard } from "./pages/dashboard.js";
8
9const router = express.Router();
10
11router.get("/root/:rootId/investor", urlAuth, htmlOnly, async (req, res) => {
12  try {
13    const { rootId } = req.params;
14    const root = await Node.findById(rootId).select("name metadata").lean();
15    if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Investor tree not found");
16
17    if (!(await isInitialized(rootId))) {
18      return sendError(res, 404, ERR.TREE_NOT_FOUND, "Investor not initialized");
19    }
20
21    const summary = await getPortfolioSummary(rootId);
22    const watchlist = await getWatchlist(rootId);
23
24    res.send(renderInvestorDashboard({
25      rootId,
26      rootName: root.name,
27      summary,
28      watchlist,
29      token: req.query.token || null,
30      userId: req.user?._id?.toString() || req.user?.id || null,
31      inApp: !!req.query.inApp,
32    }));
33  } catch (err) {
34    sendError(res, 500, ERR.INTERNAL, "Investor dashboard failed");
35  }
36});
37
38export default router;
39
1import log from "../../seed/log.js";
2import { configure, findInvestorNodes, getHoldings, getPortfolioSummary, getWatchlist, isInitialized, scaffold } from "./core.js";
3import { handleMessage } from "./handler.js";
4
5export async function init(core) {
6  configure({
7    Node: core.models.Node,
8    Note: core.models.Note,
9    metadata: core.metadata,
10  });
11
12  // -- Modes --
13  const logMode = (await import("./modes/log.js")).default;
14  const coachMode = (await import("./modes/coach.js")).default;
15  const reviewMode = (await import("./modes/review.js")).default;
16
17  core.modes.registerMode(logMode.name, logMode, "investor");
18  core.modes.registerMode(coachMode.name, coachMode, "investor");
19  core.modes.registerMode(reviewMode.name, reviewMode, "investor");
20
21  // -- enrichContext: inject portfolio awareness into ALL tree nodes --
22  const _investorCache = new Map(); // treeRootId -> { summary, ts }
23  const INVESTOR_CACHE_TTL = 60000;
24
25  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
26    if (!node?._id) return;
27
28    // At investor holding nodes: full detail
29    const invMeta = meta?.investor;
30    if (invMeta?.role === "portfolio") {
31      return;
32    }
33
34    // For all other tree nodes: compact portfolio summary
35    const treeRootId = node.rootOwner ? String(node.rootOwner) : null;
36    if (!treeRootId) return;
37
38    const cached = _investorCache.get(treeRootId);
39    if (cached && Date.now() - cached.ts < INVESTOR_CACHE_TTL) {
40      if (cached.summary) context.investorSummary = cached.summary;
41      return;
42    }
43
44    try {
45      const { getExtension } = await import("../loader.js");
46      const life = getExtension("life");
47      if (!life?.exports?.getDomainNodes) return;
48
49      const domains = await life.exports.getDomainNodes(treeRootId);
50      if (!domains.investor?.id) {
51        _investorCache.set(treeRootId, { summary: null, ts: Date.now() });
52        return;
53      }
54
55      const portfolio = await getPortfolioSummary(domains.investor.id);
56      const topHoldings = portfolio.allocation
57        .sort((a, b) => b.value - a.value)
58        .slice(0, 5)
59        .map(a => ({ ticker: a.ticker, value: a.value, percent: a.percent }));
60
61      const compact = {
62        totalValue: portfolio.totalValue,
63        totalGain: portfolio.totalGain,
64        totalGainPercent: portfolio.totalGainPercent,
65        topHoldings,
66      };
67
68      _investorCache.set(treeRootId, { summary: compact, ts: Date.now() });
69      if (portfolio.totalValue > 0) {
70        context.investorSummary = compact;
71      }
72    } catch {}
73  }, "investor");
74
75  log.info("Investor", "Loaded. The tree tracks investments.");
76
77  // ── Register apps-grid slot ──
78  try {
79    const { getExtension } = await import("../loader.js");
80    const base = getExtension("treeos-base");
81    base?.exports?.registerSlot?.("apps-grid", "investor", ({ userId, rootMap, tokenParam, tokenField, esc: e }) => {
82      const entries = rootMap.get("Investor") || [];
83      const existing = entries.map(entry =>
84        `<a class="app-active" href="/api/v1/root/${entry.id}/investor?html${tokenParam}" style="margin-right:8px;margin-bottom:6px;">${e(entry.name)}</a>`
85      ).join("");
86      return `<div class="app-card">
87        <div class="app-header"><span class="app-emoji">📈</span><span class="app-name">Investor</span></div>
88        <div class="app-desc">Track holdings, cost basis, gains and losses. Portfolio allocation. The AI helps you think through decisions without predicting.</div>
89        ${entries.length > 0
90          ? `<div style="display:flex;flex-wrap:wrap;">${existing}</div>`
91          : `<form class="app-form" method="POST" action="/api/v1/user/${userId}/apps/create">
92              ${tokenField}<input type="hidden" name="app" value="investor" />
93              <input class="app-input" name="message" placeholder="What did you buy or sell?" required />
94              <button class="app-start" type="submit">Start Investor</button>
95            </form>`}
96      </div>`;
97    }, { priority: 65 });
98  } catch {}
99
100  // ── HTML dashboard route ──
101  let router = null;
102  try {
103    const { getExtension } = await import("../loader.js");
104    const htmlExt = getExtension("html-rendering");
105    if (htmlExt) {
106      router = (await import("./htmlRoutes.js")).default;
107    }
108  } catch {}
109
110  return {
111    router,
112    exports: {
113      scaffold,
114      isInitialized,
115      findInvestorNodes,
116      getHoldings,
117      getPortfolioSummary,
118      handleMessage,
119    },
120  };
121}
122
1export default {
2  name: "investor",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Investment portfolio tracker. Log buys and sells in natural language, " +
7    "track holdings with cost basis, monitor gains and losses, review allocation. " +
8    "Each holding is a node with shares, entry price, and current price. " +
9    "Say what you bought or sold and the tree updates. Watchlist for targets. " +
10    "The AI reflects on concentration risk, unrealized losses, and allocation balance. " +
11    "Cross-domain: knows your financial health, how investments relate to your whole life. " +
12    "Type 'be' for a guided check-in on your portfolio.",
13
14  territory: "investments, portfolio, stocks, crypto, holdings, shares, buy, sell, position, allocation, gains, losses, dividends",
15  classifierHints: [
16    /\$[A-Z]{1,5}\b/,                                              // "$AAPL", "$BTC"
17    /\b(bought|sold|buy|sell|buying|selling)\b/i,                   // trade language
18    /\b(shares?|lots?|position|ticker|stock|etf|bond|crypto|bitcoin|ethereum)\b/i,
19    /\b(portfolio|holdings?|allocation|diversif|rebalance)\b/i,
20    /\b(gain|loss|return|dividend|yield|cost basis|unrealized|realized)\b/i,
21    /\b(watchlist|target|stop.?loss|entry price|exit)\b/i,
22  ],
23
24  needs: {
25    models: ["Node", "Note"],
26    services: ["hooks", "llm", "metadata"],
27  },
28
29  optional: {
30    extensions: [
31      "transactions",
32      "solana",
33      "channels",
34      "browser-bridge",
35      "html-rendering",
36      "treeos-base",
37    ],
38  },
39
40  provides: {
41    models: {},
42    routes: false,
43    tools: false,
44    jobs: false,
45    modes: true,
46
47    hooks: {
48      fires: [],
49      listens: ["enrichContext"],
50    },
51
52    cli: [
53      {
54        command: "invest [message...]",
55        scope: ["tree"],
56        description: "Investments. Log trades, check portfolio, review.",
57        method: "POST",
58        endpoint: "/root/:rootId/chat",
59        body: ["message"],
60      },
61    ],
62  },
63};
64
1import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { findInvestorNodes, getPortfolioSummary, getWatchlist } from "../core.js";
3
4export default {
5  name: "tree:investor-coach",
6  emoji: "\uD83E\uDDE0",
7  label: "Investor Coach",
8  bigMode: "tree",
9  hidden: true,
10
11  maxMessagesBeforeLoop: 6,
12  preserveContextOnLoop: true,
13
14  toolNames: [
15    "navigate-tree",
16    "get-tree-context",
17    "get-tree",
18    "get-node-notes",
19    "create-new-node",
20    "create-node-note",
21    "edit-node-note",
22    "edit-node-value",
23    "get-searched-notes-by-user",
24  ],
25
26  async buildSystemPrompt({ username, rootId, currentNodeId }) {
27    const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
28    const summary = invRoot ? await getPortfolioSummary(invRoot) : null;
29    const watchlist = invRoot ? await getWatchlist(invRoot) : [];
30    const nodes = invRoot ? await findInvestorNodes(invRoot) : null;
31
32    const holdingList = summary?.holdings?.length > 0
33      ? summary.holdings.map(h => {
34          const gainSign = h.gain >= 0 ? "+" : "";
35          return `- ${h.ticker} (${h.assetType}): ${h.shares} shares, $${h.value.toFixed(2)} (${gainSign}${h.gainPercent.toFixed(1)}%)`;
36        }).join("\n")
37      : "No holdings yet.";
38
39    const allocationList = summary?.allocation?.length > 0
40      ? summary.allocation
41          .sort((a, b) => b.percent - a.percent)
42          .map(a => `- ${a.ticker}: ${a.percent.toFixed(1)}% ($${a.value.toFixed(2)})`)
43          .join("\n")
44      : "";
45
46    const watchlistList = watchlist.length > 0
47      ? watchlist.map(w => {
48          const parts = [w.ticker];
49          if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
50          if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
51          return `- ${parts.join(", ")}`;
52        }).join("\n")
53      : "Empty.";
54
55    const totalsBlock = summary
56      ? `Total value: $${summary.totalValue.toFixed(2)}\nTotal cost: $${summary.totalCost.toFixed(2)}\nTotal gain: ${summary.totalGain >= 0 ? "+" : ""}$${summary.totalGain.toFixed(2)} (${summary.totalGain >= 0 ? "+" : ""}${summary.totalGainPercent.toFixed(1)}%)`
57      : "";
58
59    const concentration = summary?.allocation?.filter(a => a.percent > 30) || [];
60    const concentrationWarning = concentration.length > 0
61      ? `\nCONCENTRATION WARNING: ${concentration.map(c => `${c.ticker} is ${c.percent.toFixed(1)}% of portfolio`).join(", ")}`
62      : "";
63
64    const watchlistId = nodes?.watchlist?.id;
65
66    const hasHoldings = summary?.holdings?.length > 0;
67
68    return `You are ${username}'s investment coach.
69
70${hasHoldings ? `STATUS: ${summary.holdings.length} holdings tracked.` : "STATUS: No holdings yet. Help them log their first investment."}
71
72${totalsBlock ? `PORTFOLIO SNAPSHOT:\n${totalsBlock}\n` : ""}
73${holdingList ? `HOLDINGS:\n${holdingList}\n` : ""}
74${allocationList ? `ALLOCATION:\n${allocationList}${concentrationWarning}\n` : ""}
75${watchlistList ? `WATCHLIST:\n${watchlistList}\n` : ""}
76
77Your role: help ${username} think about investments clearly. Allocation balance, concentration risk, cost basis awareness, entry/exit discipline. I track and reflect. I don't predict.
78
79CAPABILITIES:
80- Create watchlist items under Watchlist (${watchlistId || "find it"}) with target prices and stop-losses
81- Set targets and stop-losses on existing holdings (edit-node-value on the holding node, metadata.investor.targetPrice / stopLoss)
82- Review allocation and flag concentration risk (any single position > 30%)
83- Search past trade notes for patterns
84
85BEHAVIOR:
86- Be direct about numbers. Don't sugarcoat losses.
87- When they ask "should I buy more X?", check current allocation, concentration, cost basis. How much of the portfolio is already in this asset?
88- When they ask about risk: look at allocation, unrealized losses, positions without stop-losses.
89- CLEAR: you track and reflect. You do NOT predict prices, recommend specific trades, or give financial advice. You help them think through their own decisions.
90- "Is this a good entry?" becomes "Your average cost is $X. This would bring it to $Y. That's Z% of your portfolio."
91- Cross-domain awareness: if you see financial health data, spending patterns, or other life context, use it naturally. "Your savings are thin this month. Adding $5000 to a volatile position right now means that money is locked up."
92- Never expose node IDs or metadata to the user.`.trim();
93  },
94};
95
1import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { findInvestorNodes, getHoldings, getWatchlist } from "../core.js";
3
4export default {
5  name: "tree:investor-log",
6  emoji: "\uD83D\uDCC8",
7  label: "Investor Log",
8  bigMode: "tree",
9  hidden: true,
10
11  maxMessagesBeforeLoop: 6,
12  preserveContextOnLoop: true,
13
14  toolNames: [
15    "navigate-tree",
16    "get-tree-context",
17    "get-tree",
18    "get-node-notes",
19    "create-new-node",
20    "create-node-note",
21    "edit-node-note",
22    "edit-node-value",
23    "edit-node-goal",
24  ],
25
26  async buildSystemPrompt({ username, rootId, currentNodeId }) {
27    const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
28    const nodes = invRoot ? await findInvestorNodes(invRoot) : null;
29    const holdings = invRoot ? await getHoldings(invRoot) : [];
30    const watchlist = invRoot ? await getWatchlist(invRoot) : [];
31
32    const holdingList = holdings.length > 0
33      ? holdings.map(h => {
34          const gainSign = h.gain >= 0 ? "+" : "";
35          return `- ${h.ticker} (${h.assetType}): ${h.shares} shares @ $${h.entryPrice} avg, current $${h.currentPrice}, value $${h.value.toFixed(2)} (${gainSign}$${h.gain.toFixed(2)}, ${gainSign}${h.gainPercent.toFixed(1)}%)`;
36        }).join("\n")
37      : "No holdings yet.";
38
39    const watchlistList = watchlist.length > 0
40      ? watchlist.map(w => {
41          const parts = [w.ticker];
42          if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
43          if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
44          return `- ${parts.join(", ")}`;
45        }).join("\n")
46      : "Empty watchlist.";
47
48    const portfolioId = nodes?.portfolio?.id;
49    const watchlistId = nodes?.watchlist?.id;
50    const logId = nodes?.log?.id;
51    const historyId = nodes?.history?.id;
52
53    return `You are logging investment transactions for ${username}.
54
55CURRENT HOLDINGS:
56${holdingList}
57
58WATCHLIST:
59${watchlistList}
60
61The user tells you about assets they bought, sold, or are tracking. Parse it and record it.
62
63WORKFLOW FOR BUYS:
641. Parse the trade: ticker/asset name, number of shares/units, price per share, asset type (stock, etf, crypto, bond, other).
652. Check if a holding node already exists under Portfolio (${portfolioId || "find it"}) for this ticker.
663. If it exists: update shares (edit-node-value, key "shares") and recalculate average entry price. Set metadata.investor with updated shares and entryPrice via edit-node-value.
674. If new: create a node under Portfolio named after the ticker. Set metadata.investor: { ticker, shares, entryPrice, currentPrice, assetType }. Set values: { value: shares*price }.
685. Write a note to Log (${logId || "find it"}): "BUY 10 AAPL @ $150. Total position: 25 shares @ $145 avg."
696. Confirm with updated position summary.
70
71WORKFLOW FOR SELLS:
721. Parse the trade: which holding, how many shares, at what price.
732. Calculate realized gain: (sellPrice - entryPrice) * sharesSold.
743. Reduce shares on the holding node. If fully sold, note it but keep the node for history.
754. Write a note to Log: "SELL 10 AAPL @ $175. Realized +$250. Remaining: 15 shares."
765. If there is a History node (${historyId || "find it"}), write a note there with the realized gain.
776. Confirm with realized gain and remaining position.
78
79PARSING RULES:
80- "bought 10 shares of AAPL at $150" = BUY 10 AAPL @ $150
81- "sold half my ETH at $3200" = SELL (half of current shares) ETH @ $3200
82- "$TSLA 5 shares at $240" = BUY 5 TSLA @ $240
83- "added more BTC at $65000" = BUY, ask how much if not specified
84- If no price given, ask.
85- If no quantity given, ask.
86- Default asset type: stock. Use crypto for known crypto (BTC, ETH, SOL, etc.).
87
88RULES:
89- One log note per trade. Terse. "BUY 10 AAPL @ $150"
90- Always update the holding node metadata and values after each trade.
91- Never expose node IDs or metadata to the user.
92- Confirm in one line with position summary.`.trim();
93  },
94};
95
1import { findExtensionRoot } from "../../../seed/tree/extensionMetadata.js";
2import { getPortfolioSummary, getWatchlist } from "../core.js";
3
4export default {
5  name: "tree:investor-review",
6  emoji: "\uD83D\uDD0D",
7  label: "Investor Review",
8  bigMode: "tree",
9  hidden: true,
10
11  maxMessagesBeforeLoop: 6,
12  preserveContextOnLoop: true,
13
14  toolNames: [
15    "navigate-tree",
16    "get-tree-context",
17    "get-tree",
18    "get-node-notes",
19    "get-searched-notes-by-user",
20  ],
21
22  async buildSystemPrompt({ username, rootId, currentNodeId }) {
23    const invRoot = await findExtensionRoot(currentNodeId || rootId, "investor") || rootId;
24    const summary = invRoot ? await getPortfolioSummary(invRoot) : null;
25    const watchlist = invRoot ? await getWatchlist(invRoot) : [];
26
27    const holdingList = summary?.holdings?.length > 0
28      ? summary.holdings
29          .sort((a, b) => b.value - a.value)
30          .map(h => {
31            const gainSign = h.gain >= 0 ? "+" : "";
32            const pct = summary.totalValue > 0 ? ((h.value / summary.totalValue) * 100).toFixed(1) : "0.0";
33            return `- ${h.ticker} (${h.assetType}): ${h.shares} shares @ $${h.entryPrice} avg, current $${h.currentPrice}, value $${h.value.toFixed(2)} (${gainSign}$${h.gain.toFixed(2)}, ${gainSign}${h.gainPercent.toFixed(1)}%), allocation ${pct}%`;
34          }).join("\n")
35      : "No holdings.";
36
37    const totalsBlock = summary
38      ? `Total value: $${summary.totalValue.toFixed(2)}\nTotal cost: $${summary.totalCost.toFixed(2)}\nTotal gain: ${summary.totalGain >= 0 ? "+" : ""}$${summary.totalGain.toFixed(2)} (${summary.totalGain >= 0 ? "+" : ""}${summary.totalGainPercent.toFixed(1)}%)`
39      : "";
40
41    // Flags
42    const flags = [];
43    if (summary?.allocation) {
44      const concentrated = summary.allocation.filter(a => a.percent > 30);
45      if (concentrated.length > 0) {
46        flags.push(`CONCENTRATION RISK: ${concentrated.map(c => `${c.ticker} at ${c.percent.toFixed(1)}%`).join(", ")}`);
47      }
48      const losers = summary.holdings.filter(h => h.gain < 0);
49      if (losers.length > 0) {
50        flags.push(`UNREALIZED LOSSES: ${losers.map(l => `${l.ticker} ${l.gain >= 0 ? "+" : ""}$${l.gain.toFixed(2)}`).join(", ")}`);
51      }
52    }
53
54    // Check for holdings without stop-losses
55    const holdingsWithoutStops = summary?.holdings?.filter(h => {
56      // Holdings don't have stopLoss in the standard fields, but we flag the concern
57      return true;
58    }) || [];
59
60    const watchlistList = watchlist.length > 0
61      ? watchlist.map(w => {
62          const parts = [w.ticker];
63          if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
64          if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
65          if (w.notes) parts.push(w.notes);
66          return `- ${parts.join(", ")}`;
67        }).join("\n")
68      : "Empty watchlist.";
69
70    const flagBlock = flags.length > 0 ? `\nFLAGS:\n${flags.map(f => `! ${f}`).join("\n")}\n` : "";
71
72    return `You are reviewing ${username}'s investment portfolio. Show the full picture. Be honest.
73
74${totalsBlock ? `PORTFOLIO:\n${totalsBlock}\n` : ""}
75HOLDINGS:
76${holdingList}
77${flagBlock}
78WATCHLIST:
79${watchlistList}
80
81BEHAVIOR:
82- Answer their question directly with numbers.
83- "How is my portfolio doing?" = show total value, gain/loss, top and bottom performers.
84- "What's my allocation?" = show each holding's percentage of total.
85- "How much am I up/down?" = total unrealized gain/loss with per-holding breakdown.
86- Flag concentration risk: any single holding over 30% of portfolio.
87- Flag unrealized losses with the exact dollar amount.
88- Note if holdings lack stop-losses set. Disciplined investors define their exit.
89- Show watchlist items and how current prices compare to targets.
90- Be factual. Don't moralize. Just show the numbers and let them decide.
91- Never expose node IDs or metadata to the user.`.trim();
92  },
93};
94
1/**
2 * Investor Dashboard
3 *
4 * Portfolio overview. Holdings with gains/losses, allocation,
5 * watchlist, concentration warnings.
6 */
7
8import { renderAppDashboard } from "../../html-rendering/html/appDashboard.js";
9
10const TYPE_COLORS = {
11  stock: "#667eea",
12  etf: "#48bb78",
13  crypto: "#f6ad55",
14  bond: "#4fd1c5",
15  "real-estate": "#ed64a6",
16  other: "#718096",
17};
18
19export function renderInvestorDashboard({ rootId, rootName, summary, watchlist, token, userId, inApp }) {
20  const s = summary || {};
21  const holdings = s.holdings || [];
22  const allocation = s.allocation || [];
23  const totalValue = s.totalValue || 0;
24  const totalGain = s.totalGain || 0;
25  const totalGainPercent = s.totalGainPercent || 0;
26  const totalCost = s.totalCost || 0;
27  const wl = watchlist || [];
28
29  // Hero: total portfolio value
30  const gainSign = totalGain >= 0 ? "+" : "";
31  const hero = {
32    value: `$${totalValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
33    label: `${gainSign}$${totalGain.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${gainSign}${totalGainPercent.toFixed(1)}%)`,
34    color: totalGain >= 0 ? "#48bb78" : "#fc8181",
35    sub: `Cost basis: $${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
36  };
37
38  // Stats
39  const stats = [];
40  stats.push({ value: String(holdings.length), label: "holdings" });
41  if (wl.length > 0) stats.push({ value: String(wl.length), label: "watching" });
42  const concentration = allocation.filter(a => a.percent > 30);
43  if (concentration.length > 0) {
44    stats.push({ value: concentration.map(c => c.ticker).join(", "), label: ">30% concentration" });
45  }
46
47  // Bars: allocation by holding
48  const bars = allocation
49    .sort((a, b) => b.value - a.value)
50    .map(a => {
51      const h = holdings.find(h => h.ticker === a.ticker);
52      const assetType = h?.assetType || "stock";
53      return {
54        label: `${a.ticker} (${a.percent.toFixed(1)}%)`,
55        current: a.value,
56        goal: totalValue,
57        color: TYPE_COLORS[assetType] || "#a78bfa",
58        unit: "$",
59      };
60    });
61
62  // Cards
63  const cards = [];
64
65  // Holdings card with gain/loss detail
66  if (holdings.length > 0) {
67    cards.push({
68      title: "Holdings",
69      items: holdings
70        .sort((a, b) => b.value - a.value)
71        .map(h => {
72          const gs = h.gain >= 0 ? "+" : "";
73          return {
74            text: `${h.ticker} . ${h.shares} ${h.assetType === "crypto" ? "units" : "shares"} @ $${h.entryPrice}`,
75            detail: [`$${h.value.toFixed(2)}`, `${gs}$${h.gain.toFixed(2)}`, `${gs}${h.gainPercent.toFixed(1)}%`],
76            sub: h.assetType,
77          };
78        }),
79    });
80  }
81
82  // Watchlist card
83  if (wl.length > 0) {
84    cards.push({
85      title: "Watchlist",
86      items: wl.map(w => {
87        const parts = [];
88        if (w.targetPrice) parts.push(`target $${w.targetPrice}`);
89        if (w.stopLoss) parts.push(`stop $${w.stopLoss}`);
90        return {
91          text: w.ticker,
92          sub: parts.length > 0 ? parts.join(" . ") : null,
93        };
94      }),
95    });
96  }
97
98  return renderAppDashboard({
99    rootId,
100    rootName: rootName || "Investor",
101    token,
102    userId,
103    inApp: !!inApp,
104    subtitle: new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }),
105    hero,
106    stats,
107    bars,
108    cards,
109    chatBar: {
110      placeholder: "Log a trade or ask about your portfolio...",
111      endpoint: `/api/v1/root/${rootId}/chat`,
112    },
113    emptyState: holdings.length === 0
114      ? { title: "No holdings yet", message: "Tell me what you bought. The tree tracks your portfolio." }
115      : null,
116  });
117}
118
0 stars
0 flags
React from the CLI: treeos ext star investor

Comments

Loading comments...

Post comments from the CLI: treeos ext comment investor "your comment"
Max 3 comments per extension. One star and one flag per user.