phase-9: domain-driven architecture complete

- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
Sai Kiran Vella
2026-06-06 13:21:24 -04:00
committed by saikiranvella
parent 83116baa3c
commit 96a752ecf7
88 changed files with 3576 additions and 3493 deletions
@@ -0,0 +1,178 @@
import { SIGNAL, YahooFinanceClient } from '../../domains/shared';
import type {
PortfolioHolding,
Signal,
ScreenerResult,
AssetResult,
AdviceRow,
PositionCalc,
AdviceOutput,
} from '../../domains/shared';
export class PortfolioAdvisor {
constructor(private readonly client: YahooFinanceClient) {}
async advise(
holdings: PortfolioHolding[],
screenedResults: ScreenerResult,
): Promise<AdviceRow[]> {
const resultMap: Record<string, AssetResult> = {};
for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) {
const t = r.asset.ticker;
resultMap[t] = r;
resultMap[t.replace(/-/g, '.')] = r;
resultMap[t.replace(/\./g, '-')] = r;
}
const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
return holdings.map((holding) => {
const type = (holding.type ?? 'stock').toLowerCase();
const source = holding.source ?? '—';
const price: number | null =
type === 'crypto'
? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
return type === 'crypto'
? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price))
: this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
});
}
private stockRow(
holding: PortfolioHolding,
price: number | null,
source: string,
result: AssetResult | undefined,
): AdviceRow {
if (!result) {
return this.row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened',
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
});
}
return this.row(
holding,
price,
source,
result.signal,
result.inflated.label,
result.fundamental.label,
this.advice(result.signal, holding, price),
);
}
private row(
holding: PortfolioHolding,
currentPrice: number | null,
source: string,
signal: Signal | '—',
inflated: string,
fundamental: string,
{ action, reason }: AdviceOutput,
): AdviceRow {
const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice);
return {
ticker: holding.ticker,
type: holding.type ?? 'stock',
source,
shares: holding.shares,
costBasis: holding.costBasis,
currentPrice,
marketValue,
totalCost,
gainLossPct,
signal,
inflated,
fundamental,
advice: action,
reason,
};
}
private position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
return {
totalCost: (holding.costBasis * holding.shares).toFixed(2),
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
gainLossPct:
currentPrice != null && holding.costBasis > 0
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
: null,
};
}
private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this.position(holding, price);
const g = parseFloat(gainLossPct ?? 'NaN');
if (gainLossPct == null)
return {
action: '⚪ No price data',
reason: 'Crypto — track price and manage risk manually.',
};
if (g > 100)
return {
action: '🟠 Consider taking profits',
reason: 'Up significantly — no fundamental analysis for crypto.',
};
if (g < -30)
return {
action: '🔴 Review position',
reason: 'Down significantly — no fundamental analysis for crypto.',
};
return {
action: '🟡 Hold',
reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.',
};
}
private advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this.position(holding, price);
const gain = parseFloat(gainLossPct ?? '0');
switch (signal) {
case SIGNAL.STRONG_BUY:
return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
case SIGNAL.MOMENTUM:
return {
action: '🟡 Hold',
reason:
gain > 30
? 'Up on momentum — consider partial profit-taking.'
: 'Set a stop-loss — not fundamentally justified.',
};
case SIGNAL.SPECULATION:
return {
action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)',
reason:
gain > 20
? 'In profit on speculation — take partial profits.'
: 'Overvalued fundamentally. Keep position small.',
};
case SIGNAL.NEUTRAL:
return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
case SIGNAL.AVOID:
return {
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
reason:
gain > 0
? "Fails both analyses — you're in profit, take it."
: 'Fails both analyses — stop the loss from growing.',
};
default:
return { action: '⚪ Review', reason: 'Signal unclear.' };
}
}
private async cryptoPrices(holdings: PortfolioHolding[]): Promise<Record<string, number | null>> {
const prices: Record<string, number | null> = {};
for (const h of holdings) {
try {
const summary = await this.client.fetchSummary(h.ticker);
prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null;
} catch {
prices[h.ticker.toUpperCase()] = null;
}
}
return prices;
}
}
@@ -0,0 +1,71 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
import { PortfolioAdvisor } from './PortfolioAdvisor';
import type { PortfolioHolding } from '../../domains/shared';
import { holdingSchema } from '../../domains/shared/types/schemas';
export class FinanceController {
constructor(
private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository,
private readonly advisor: PortfolioAdvisor,
) {}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this));
app.get('/api/finance/market-context', this.marketContext.bind(this));
}
private async portfolio(_req: FastifyRequest, reply: FastifyReply) {
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const { holdings } = this.repo.read();
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
}
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => h.ticker.toUpperCase());
const results =
screenable.length > 0
? await this.engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await this.advisor.advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
}
private async addHolding(req: FastifyRequest, reply: FastifyReply) {
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source });
return reply.code(201).send(entry);
}
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const removed = this.repo.remove(ticker);
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true };
}
private async marketContext() {
return this.engine.getMarketContext();
}
}
+3
View File
@@ -0,0 +1,3 @@
// Portfolio domain — holdings management and advice
export { FinanceController } from './finance.controller';
export { PortfolioAdvisor } from './PortfolioAdvisor';