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:
committed by
saikiranvella
parent
83116baa3c
commit
96a752ecf7
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Portfolio domain — holdings management and advice
|
||||
export { FinanceController } from './finance.controller';
|
||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
Reference in New Issue
Block a user