feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
This commit is contained in:
@@ -3,108 +3,135 @@ import * as assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
STARTER_WATCHLIST,
|
||||
STARTER_PACK_FOCUSED,
|
||||
STARTER_PACK_STANDARD,
|
||||
ONBOARDING_DISCLAIMER,
|
||||
defaultDrawdownTolerancePct,
|
||||
defaultRiskTolerance,
|
||||
starterPackFor,
|
||||
resolveWorkspaceDefaults,
|
||||
complexityFromExperience,
|
||||
defaultDensity,
|
||||
type StarterSymbol,
|
||||
} from "../starter.ts";
|
||||
|
||||
const FORBIDDEN = [
|
||||
/buy\b/i,
|
||||
/sell\b/i,
|
||||
/you should\b/i,
|
||||
/add to your\b/i,
|
||||
/rotate into\b/i,
|
||||
/action needed\b/i,
|
||||
];
|
||||
|
||||
function assertNoTradeVerbs(items: StarterSymbol[], label: string) {
|
||||
for (const item of items) {
|
||||
const hit = FORBIDDEN.find((rx) => rx.test(item.reason));
|
||||
assert.equal(hit, undefined, `${label}: forbidden verb in reason for ${item.symbol}`);
|
||||
assert.ok(item.reason.length > 0, `${label}: empty reason for ${item.symbol}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("onboarding/starter", () => {
|
||||
describe("STARTER_WATCHLIST", () => {
|
||||
it("has exactly 9 entries", () => {
|
||||
assert.equal(STARTER_WATCHLIST.length, 9);
|
||||
describe("STARTER_PACK_FOCUSED / STARTER_WATCHLIST", () => {
|
||||
it("focused pack has liquid exemplars (no thin microcaps as default)", () => {
|
||||
const symbols = STARTER_PACK_FOCUSED.map((s) => s.symbol);
|
||||
assert.ok(symbols.includes("VTI"));
|
||||
assert.ok(symbols.includes("AAPL"));
|
||||
assert.ok(!symbols.includes("IREN"));
|
||||
assert.ok(!symbols.includes("CIFR"));
|
||||
assert.equal(STARTER_WATCHLIST.length, STARTER_PACK_FOCUSED.length);
|
||||
});
|
||||
|
||||
it("matches the prescribed symbol+tickerKind order", () => {
|
||||
const expected: Array<{ symbol: string; tickerKind: string }> = [
|
||||
{ symbol: "IREN", tickerKind: "equity" },
|
||||
{ symbol: "CIFR", tickerKind: "equity" },
|
||||
{ symbol: "ASST", tickerKind: "equity" },
|
||||
{ symbol: "SLNH", tickerKind: "equity" },
|
||||
{ symbol: "BKKT", tickerKind: "equity" },
|
||||
{ symbol: "NUAI", tickerKind: "equity" },
|
||||
{ symbol: "NVDA", tickerKind: "equity" },
|
||||
{ symbol: "BTC", tickerKind: "crypto" },
|
||||
{ symbol: "SATA", tickerKind: "equity" },
|
||||
];
|
||||
assert.equal(STARTER_WATCHLIST.length, expected.length);
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
const got: StarterSymbol = STARTER_WATCHLIST[i];
|
||||
assert.equal(got.symbol, expected[i].symbol, `item #${i} symbol`);
|
||||
assert.equal(got.tickerKind, expected[i].tickerKind, `item #${i} tickerKind`);
|
||||
}
|
||||
it("every focused entry has tickerKind and reason without trade verbs", () => {
|
||||
assertNoTradeVerbs(STARTER_PACK_FOCUSED, "focused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("starterPackFor", () => {
|
||||
it("standard includes focused symbols plus benchmarks", () => {
|
||||
const pack = starterPackFor("standard");
|
||||
const symbols = pack.map((s) => s.symbol);
|
||||
assert.ok(symbols.includes("VTI"));
|
||||
assert.ok(symbols.includes("SPY"));
|
||||
assert.ok(symbols.includes("NVDA"));
|
||||
assertNoTradeVerbs(pack, "standard");
|
||||
});
|
||||
|
||||
it("BTC has tickerKind 'crypto' while all others are 'equity'", () => {
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
if (item.symbol === "BTC") {
|
||||
assert.equal(item.tickerKind, "crypto");
|
||||
} else {
|
||||
assert.equal(item.tickerKind, "equity");
|
||||
}
|
||||
}
|
||||
it("full adds optional higher-vol research examples", () => {
|
||||
const pack = starterPackFor("full");
|
||||
const symbols = pack.map((s) => s.symbol);
|
||||
assert.ok(symbols.includes("SPY"));
|
||||
assert.ok(symbols.includes("IREN") || symbols.includes("BTC"));
|
||||
assertNoTradeVerbs(pack, "full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceDefaults", () => {
|
||||
it("maps never_invested → beginner + focused + focused pack", () => {
|
||||
const d = resolveWorkspaceDefaults({ experienceStage: "never_invested" });
|
||||
assert.equal(d.complexity, "beginner");
|
||||
assert.equal(d.density, "focused");
|
||||
assert.equal(d.drawdownTolerancePct, -20);
|
||||
assert.deepEqual(
|
||||
d.starterPack.map((s) => s.symbol),
|
||||
STARTER_PACK_FOCUSED.map((s) => s.symbol),
|
||||
);
|
||||
});
|
||||
|
||||
it("every entry has a non-empty reason string", () => {
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
assert.ok(item.reason.length > 0, `reason for ${item.symbol} is empty`);
|
||||
}
|
||||
it("maps active_self_directed → advanced + full", () => {
|
||||
const d = resolveWorkspaceDefaults({ experienceStage: "active_self_directed" });
|
||||
assert.equal(d.complexity, "advanced");
|
||||
assert.equal(d.density, "full");
|
||||
assert.equal(d.riskTolerance, "aggressive");
|
||||
});
|
||||
|
||||
it("reasons contain no imperative trade verbs (ADR-0007 / P6)", () => {
|
||||
const forbidden = [
|
||||
/buy\b/i,
|
||||
/sell\b/i,
|
||||
/you should\b/i,
|
||||
/add to your\b/i,
|
||||
/rotate into\b/i,
|
||||
/action needed\b/i,
|
||||
];
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
const hit = forbidden.find((rx) => rx.test(item.reason));
|
||||
assert.equal(hit, undefined, `forbidden verb in reason for ${item.symbol}`);
|
||||
}
|
||||
it("respects explicit density override", () => {
|
||||
const d = resolveWorkspaceDefaults({
|
||||
experienceStage: "never_invested",
|
||||
density: "standard",
|
||||
goal: "protect",
|
||||
horizon: "short",
|
||||
});
|
||||
assert.equal(d.density, "standard");
|
||||
assert.equal(d.goal, "protect");
|
||||
assert.equal(d.horizon, "short");
|
||||
assert.ok(d.starterPack.length >= STARTER_PACK_STANDARD.length - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complexityFromExperience / defaultDensity", () => {
|
||||
it("maps stages", () => {
|
||||
assert.equal(complexityFromExperience("never_invested"), "beginner");
|
||||
assert.equal(complexityFromExperience("some_stocks"), "intermediate");
|
||||
assert.equal(complexityFromExperience("active_self_directed"), "advanced");
|
||||
assert.equal(defaultDensity("never_invested"), "focused");
|
||||
assert.equal(defaultDensity("some_stocks"), "standard");
|
||||
assert.equal(defaultDensity("active_self_directed"), "full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultDrawdownTolerancePct", () => {
|
||||
it("returns -20 for beginner", () => {
|
||||
it("returns expected levels", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("beginner"), -20);
|
||||
});
|
||||
|
||||
it("returns -30 for intermediate", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("intermediate"), -30);
|
||||
});
|
||||
|
||||
it("returns -40 for advanced", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("advanced"), -40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultRiskTolerance", () => {
|
||||
it("returns 'moderate' for beginner", () => {
|
||||
it("returns expected levels", () => {
|
||||
assert.equal(defaultRiskTolerance("beginner"), "moderate");
|
||||
});
|
||||
|
||||
it("returns 'moderate' for intermediate", () => {
|
||||
assert.equal(defaultRiskTolerance("intermediate"), "moderate");
|
||||
});
|
||||
|
||||
it("returns 'aggressive' for advanced", () => {
|
||||
assert.equal(defaultRiskTolerance("advanced"), "aggressive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ONBOARDING_DISCLAIMER", () => {
|
||||
it("contains the required keywords", () => {
|
||||
assert.ok(
|
||||
ONBOARDING_DISCLAIMER.toLowerCase().includes("educational"),
|
||||
"missing 'educational'",
|
||||
);
|
||||
assert.ok(
|
||||
ONBOARDING_DISCLAIMER.toLowerCase().includes("not financial advice"),
|
||||
"missing 'not financial advice'",
|
||||
);
|
||||
it("is short operational setup copy", () => {
|
||||
assert.ok(ONBOARDING_DISCLAIMER.length > 0);
|
||||
assert.ok(ONBOARDING_DISCLAIMER.toLowerCase().includes("watchlist"));
|
||||
assert.ok(!ONBOARDING_DISCLAIMER.toLowerCase().includes("educational"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
// ADR-0007: Zero imperative trade verbs anywhere in this module.
|
||||
// Pure data + helpers for the first-login onboarding wizard — no deps.
|
||||
// Workspace setup helpers: experience interview → density, risk defaults, starter packs.
|
||||
// Product framing: investment management with in-context guidance (not a course app).
|
||||
|
||||
export type Complexity = "beginner" | "intermediate" | "advanced";
|
||||
|
||||
/** How much investing experience the user reports. */
|
||||
export type ExperienceStage = "never_invested" | "some_stocks" | "active_self_directed";
|
||||
|
||||
/** What they care about most when managing money. */
|
||||
export type Goal = "grow" | "income" | "protect";
|
||||
|
||||
/** Time horizon for money they will track here. */
|
||||
export type Horizon = "short" | "medium" | "long";
|
||||
|
||||
/** UI density for the management workbench. */
|
||||
export type Density = "focused" | "standard" | "full";
|
||||
|
||||
/** How much market jargon to surface by default. */
|
||||
export type JargonComfort = "plain" | "mixed" | "technical";
|
||||
|
||||
/**
|
||||
* One entry for the starter watchlist shown to a new user during onboarding.
|
||||
* The reason field is plain-English and purely educational/descriptive.
|
||||
* One entry for a starter watchlist shown during workspace setup.
|
||||
* Reasons are factual / product-context only (what the symbol is useful to track), not picks.
|
||||
*/
|
||||
export interface StarterSymbol {
|
||||
symbol: string;
|
||||
@@ -14,67 +30,153 @@ export interface StarterSymbol {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The starter watchlist presented to every new user.
|
||||
* Exactly 9 entries, fixed order.
|
||||
*/
|
||||
export const STARTER_WATCHLIST: StarterSymbol[] = [
|
||||
export type StarterPackId = "focused" | "standard" | "full";
|
||||
|
||||
/** Liquid exemplars for newer users setting up a book to manage. */
|
||||
export const STARTER_PACK_FOCUSED: StarterSymbol[] = [
|
||||
{
|
||||
symbol: "IREN",
|
||||
tickerKind: "equity",
|
||||
symbol: "VTI",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"Iris Energy — a Bitcoin-mining and energy-infrastructure company focused on sustainable operations.",
|
||||
"Vanguard Total Stock Market ETF — a broad U.S. equity basket used as a market baseline in research views.",
|
||||
},
|
||||
{
|
||||
symbol: "CIFR",
|
||||
tickerKind: "equity",
|
||||
symbol: "VXUS",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"Cipher Mining — a digital-asset mining operator with large-scale energy infrastructure.",
|
||||
"Vanguard Total International Stock ETF — non-U.S. equities for multi-region context.",
|
||||
},
|
||||
{
|
||||
symbol: "ASST",
|
||||
tickerKind: "equity",
|
||||
symbol: "BND",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"Asseco Systems — an enterprise technology and blockchain-services provider.",
|
||||
"Vanguard Total Bond Market ETF — investment-grade U.S. bonds for fixed-income context.",
|
||||
},
|
||||
{
|
||||
symbol: "SLNH",
|
||||
symbol: "AAPL",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Solena AI — a data-center infrastructure company supporting AI and compute workloads.",
|
||||
"Apple — large-cap equity with full SEC filings coverage for ownership and company research practice.",
|
||||
},
|
||||
{
|
||||
symbol: "BKKT",
|
||||
symbol: "MSFT",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Bakkt — a regulated digital-asset exchange built on institutional-grade infrastructure.",
|
||||
"Microsoft — large-cap software/cloud equity with deep institutional ownership history.",
|
||||
},
|
||||
{
|
||||
symbol: "NUAI",
|
||||
symbol: "JNJ",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"NuAerospace — an aerospace-and-defense company pursuing advanced space systems.",
|
||||
"Johnson & Johnson — large healthcare company often used when reviewing dividends and mature businesses.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Broader liquid set for users with some market experience. */
|
||||
export const STARTER_PACK_STANDARD: StarterSymbol[] = [
|
||||
...STARTER_PACK_FOCUSED,
|
||||
{
|
||||
symbol: "SPY",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"SPDR S&P 500 ETF — the common U.S. large-cap benchmark used in relative performance views.",
|
||||
},
|
||||
{
|
||||
symbol: "QQQ",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"Invesco QQQ — Nasdaq-100 basket for growth-tilted market context.",
|
||||
},
|
||||
{
|
||||
symbol: "XLK",
|
||||
tickerKind: "etf",
|
||||
reason:
|
||||
"Technology Select Sector SPDR — sector-level technology exposure for rotation context.",
|
||||
},
|
||||
{
|
||||
symbol: "NVDA",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"NVIDIA — a semiconductor leader in GPUs and AI-accelerator hardware.",
|
||||
"NVIDIA — large semiconductor name with high research data coverage (price, options, ownership).",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Optional higher-volatility research symbols for full-density users who opt into a research pack.
|
||||
* Not used as the default first watchlist for never_invested users.
|
||||
*/
|
||||
export const STARTER_PACK_FULL_EXTRA: StarterSymbol[] = [
|
||||
{
|
||||
symbol: "IREN",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Iris Energy — Bitcoin-mining and energy-infrastructure company (higher-volatility research example).",
|
||||
},
|
||||
{
|
||||
symbol: "CIFR",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Cipher Mining — digital-asset mining operator (higher-volatility research example).",
|
||||
},
|
||||
{
|
||||
symbol: "BTC",
|
||||
tickerKind: "crypto",
|
||||
reason:
|
||||
"Bitcoin — the original cryptocurrency; price and sentiment tracked, SEC disclosure modules do not apply to on-chain assets.",
|
||||
},
|
||||
{
|
||||
symbol: "SATA",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Solid Alpha Technology Acquisition — a SPAC holding a technology-focused portfolio.",
|
||||
"Bitcoin — crypto price and sentiment only; SEC ownership and filings modules do not apply.",
|
||||
},
|
||||
];
|
||||
|
||||
/** @deprecated Prefer starterPackFor(density). Kept as focused pack alias for older imports/tests. */
|
||||
export const STARTER_WATCHLIST: StarterSymbol[] = STARTER_PACK_FOCUSED;
|
||||
|
||||
export function complexityFromExperience(stage: ExperienceStage): Complexity {
|
||||
switch (stage) {
|
||||
case "never_invested":
|
||||
return "beginner";
|
||||
case "some_stocks":
|
||||
return "intermediate";
|
||||
case "active_self_directed":
|
||||
return "advanced";
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultDensity(stage: ExperienceStage): Density {
|
||||
switch (stage) {
|
||||
case "never_invested":
|
||||
return "focused";
|
||||
case "some_stocks":
|
||||
return "standard";
|
||||
case "active_self_directed":
|
||||
return "full";
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultJargonComfort(stage: ExperienceStage): JargonComfort {
|
||||
switch (stage) {
|
||||
case "never_invested":
|
||||
return "plain";
|
||||
case "some_stocks":
|
||||
return "mixed";
|
||||
case "active_self_directed":
|
||||
return "technical";
|
||||
}
|
||||
}
|
||||
|
||||
export function starterPackIdFor(density: Density): StarterPackId {
|
||||
return density;
|
||||
}
|
||||
|
||||
export function starterPackFor(density: Density): StarterSymbol[] {
|
||||
switch (density) {
|
||||
case "focused":
|
||||
return STARTER_PACK_FOCUSED;
|
||||
case "standard":
|
||||
return STARTER_PACK_STANDARD;
|
||||
case "full":
|
||||
return [...STARTER_PACK_STANDARD, ...STARTER_PACK_FULL_EXTRA];
|
||||
}
|
||||
}
|
||||
|
||||
/** Default drawdown tolerance (percent) per complexity level. */
|
||||
export function defaultDrawdownTolerancePct(complexity: Complexity): number {
|
||||
switch (complexity) {
|
||||
@@ -101,6 +203,43 @@ export function defaultRiskTolerance(
|
||||
}
|
||||
}
|
||||
|
||||
/** Legal disclaimer shown during onboarding. */
|
||||
/** Resolve full workspace defaults from interview answers. */
|
||||
export function resolveWorkspaceDefaults(input: {
|
||||
experienceStage: ExperienceStage;
|
||||
goal?: Goal;
|
||||
horizon?: Horizon;
|
||||
density?: Density;
|
||||
jargonComfort?: JargonComfort;
|
||||
}): {
|
||||
experienceStage: ExperienceStage;
|
||||
goal: Goal;
|
||||
horizon: Horizon;
|
||||
density: Density;
|
||||
jargonComfort: JargonComfort;
|
||||
complexity: Complexity;
|
||||
riskTolerance: "conservative" | "moderate" | "aggressive";
|
||||
drawdownTolerancePct: number;
|
||||
starterPack: StarterSymbol[];
|
||||
} {
|
||||
const experienceStage = input.experienceStage;
|
||||
const complexity = complexityFromExperience(experienceStage);
|
||||
const density = input.density ?? defaultDensity(experienceStage);
|
||||
const jargonComfort = input.jargonComfort ?? defaultJargonComfort(experienceStage);
|
||||
const goal = input.goal ?? "grow";
|
||||
const horizon = input.horizon ?? "long";
|
||||
return {
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
density,
|
||||
jargonComfort,
|
||||
complexity,
|
||||
riskTolerance: defaultRiskTolerance(complexity),
|
||||
drawdownTolerancePct: defaultDrawdownTolerancePct(complexity),
|
||||
starterPack: starterPackFor(density),
|
||||
};
|
||||
}
|
||||
|
||||
/** Short note for workspace setup (not product branding). */
|
||||
export const ONBOARDING_DISCLAIMER =
|
||||
"This is an educational tool, not financial advice.";
|
||||
"Starter symbols seed your watchlist; change them anytime.";
|
||||
|
||||
Reference in New Issue
Block a user