Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6575f94
docs: add currency support design spec for issue #7
furic Apr 27, 2026
3759004
docs: add implementation plan for international currency support
furic Apr 27, 2026
ff9dbd7
feat(util): add formatMoney helper for currency-aware display
furic Apr 27, 2026
e84c5c7
feat(fx): add fetchFxRates module using yahoo-finance2 FX pairs
furic Apr 27, 2026
119246f
feat(config): rename totalPortfolioValueUSD → totalPortfolioValue + d…
furic Apr 27, 2026
afa8546
feat(fetch): capture per-ticker currency + apply sub-unit fix
furic Apr 27, 2026
e8ded69
feat(fetch): convert per-ticker prices to defaultCurrency via Yahoo FX
furic Apr 27, 2026
c431591
feat(technicals): convert OHLCV to defaultCurrency before computing i…
furic Apr 27, 2026
65c6e9e
feat(email): currency-aware fmt$, header label, footer caveat
furic Apr 27, 2026
ba3446b
feat(intraday): currency-aware intraday alerts + originalCurrency on …
furic Apr 27, 2026
e5cc4e9
fix(intraday): add defaultCurrency header labels + fix hardcoded pric…
furic Apr 27, 2026
c4aee64
fix(task8): console log currency label + hasCrossCurrency comment
furic Apr 27, 2026
6e195c6
feat(weekly): currency-aware weekly rebalancing email
furic Apr 27, 2026
c6c7942
fix(weekly): move cross-currency caveat inside card boundary
furic Apr 27, 2026
7fe8290
feat(telegram): currency-aware totals + cross-currency caveat
furic Apr 27, 2026
9cedd0e
fix(telegram): replace remaining hardcoded $ on limit/SMA prices with…
furic Apr 27, 2026
7b40415
feat(ai): currency-aware AI prompts with original-currency audit
furic Apr 27, 2026
4f94f85
fix(ai): remove double space in cross-currency price annotation
furic Apr 27, 2026
cc7ae75
docs: update for new totalPortfolioValue + defaultCurrency schema
furic Apr 27, 2026
23896ce
chore: prettier reformat long line + gitignore adjustment
furic Apr 27, 2026
8682522
test(smoke): add currency conversion + FX + money format smoke scripts
furic Apr 28, 2026
07fe815
test: add node:test unit tests for formatMoney, applyFxRate, SUB_UNIT…
furic Apr 28, 2026
4988a9c
chore: add npm run smoke for live API smoke tests
furic Apr 28, 2026
37a4e15
docs: add npm test + smoke commands, util.ts + fetchFx.ts to architec…
furic Apr 28, 2026
42f4f86
refactor: rename scratch/ → smoke/ for clarity
furic Apr 28, 2026
51db14a
chore: remove currency support planning artifacts (implementation com…
furic Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ jobs:

- name: Format check
run: npm run format:check

- name: Unit tests
run: npm test
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ dist/
config.json
state/
.DS_Store
.claude/
.claude/settings.local.json
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ npm run intraday # Run intraday alert check (compares vs morning)
npm run weekly # Run weekly rebalancing report
npm run refresh -- SMH # Re-analyze single ticker with after-hours price
npm run start # Production daily entry point
npx tsc --noEmit # Type-check without emitting
npm run typecheck # Type-check without emitting
npm test # Unit tests (pure functions, no network, CI-safe)
npm run smoke # Live API smoke tests (hits Yahoo Finance — run manually)
```

## Architecture
Expand All @@ -48,6 +50,8 @@ src/index.ts (entry point — parses --weekly/--intraday/--refresh flags, wires
→ src/intradayEmail.ts # Intraday + refresh alert emails + Resend
→ src/weeklyEmail.ts # Weekly rebalancing HTML email + Resend
→ src/telegram.ts # Telegram delivery (daily + intraday + weekly + refresh message builders)
→ src/fetchFx.ts # Fetches FX rates from Yahoo Finance (GBPUSD=X convention), one batch per run
→ src/util.ts # Pure helpers: formatMoney, applyFxRate, SUB_UNIT_FIX, escapeHtmlAttr/Text
```

## Config Architecture
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ npm run dev # Daily brief (email + Telegram)
npm run intraday # Intraday alert check (compares vs morning)
npm run weekly # Weekly rebalancing report
npm run refresh -- SMH # Re-analyze single ticker with after-hours price
npm test # Unit tests (pure functions, no network)
npm run smoke # Live API smoke tests (requires network + config.json)
```

</details>
Expand Down
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"TSM": 10,
"VOO": 1
},
"totalPortfolioValueUSD": 50000,
"totalPortfolioValue": 50000,
"defaultCurrency": "USD",
"intradayAlerts": {
"enabled": true,
"confidenceIncreaseThreshold": 10,
Expand Down
4 changes: 3 additions & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ For each ticker in your target portfolio:
3. **Gap %** = target % − current %
4. **Suggested buy** = gap % × portfolio value (only when underweight)

Portfolio value uses the higher of actual holdings value or configured `totalPortfolioValueUSD`.
Portfolio value uses the higher of actual holdings value or configured `totalPortfolioValue`.

The system supports portfolios denominated in any of the following currencies: USD, GBP, EUR, AUD, CAD, JPY, CHF, HKD, SGD, NZD. Set `defaultCurrency` in your config to your preferred display currency. Tickers quoted in other currencies (e.g. UK LSE stocks in GBp) are auto-detected, unit-fixed (LSE pence ÷ 100), and FX-converted via Yahoo Finance for display.

### Dynamic P/E Signals

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
"intraday": "tsx src/index.ts --intraday",
"refresh": "tsx src/index.ts --refresh",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\""
"test": "node --import=tsx/esm --test test/**/*.test.ts",
"smoke": "npx tsx smoke/smoke-fx.ts && npx tsx smoke/smoke-conversion.ts && npx tsx smoke/smoke-money-format.ts",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\""
},
"repository": {
"type": "git",
Expand Down
28 changes: 28 additions & 0 deletions smoke/smoke-conversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fetchPrices } from "../src/fetchPrices.js";

(async () => {
const result = await fetchPrices(["AAPL", "TSCO.L", "SAP.DE"], "USD");
for (const q of result.quotes) {
console.log(
`${q.ticker} price=${q.price.toFixed(2)} ${q.currency} (originalCurrency=${q.originalCurrency})`,
);
}
console.log("skipped:", result.skipped);

const aapl = result.quotes.find((q) => q.ticker === "AAPL");
const tsco = result.quotes.find((q) => q.ticker === "TSCO.L");
const sap = result.quotes.find((q) => q.ticker === "SAP.DE");

const ok =
aapl?.currency === "USD" &&
aapl?.originalCurrency === "USD" &&
(!tsco ||
(tsco.currency === "USD" &&
tsco.originalCurrency === "GBP" &&
tsco.price > 1 &&
tsco.price < 100)) &&
(!sap || (sap.currency === "USD" && sap.originalCurrency === "EUR" && sap.price > 50));

console.log(ok ? "\nPASS" : "\nFAIL");
if (!ok) process.exit(1);
})();
18 changes: 18 additions & 0 deletions smoke/smoke-fx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { fetchFxRates } from "../src/fetchFx.js";

(async () => {
const rates = await fetchFxRates(["GBP", "EUR", "JPY", "USD"], "USD");
console.log("USD ← GBP:", rates.GBP);
console.log("USD ← EUR:", rates.EUR);
console.log("USD ← JPY:", rates.JPY);
console.log("USD ← USD:", rates.USD);

const ok =
typeof rates.GBP === "number" && rates.GBP > 0.5 && rates.GBP < 2.5 &&
typeof rates.EUR === "number" && rates.EUR > 0.5 && rates.EUR < 2.0 &&
typeof rates.JPY === "number" && rates.JPY > 0.001 && rates.JPY < 0.05 &&
rates.USD === 1;

console.log(ok ? "\nPASS — rates within sanity bounds." : "\nFAIL — rates out of expected ranges.");
if (!ok) process.exit(1);
})();
34 changes: 34 additions & 0 deletions smoke/smoke-money-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { formatMoney } from "../src/util.js";

const cases: Array<[number, string, string]> = [
// [amount, currency, expected]
[1234, "USD", "$1,234"],
[1234.56, "USD", "$1,235"],
[0, "USD", "$0"],
[-500, "USD", "-$500"],
[1234, "GBP", "£1,234"],
[1234, "EUR", "€1,234"],
[1234, "JPY", "¥1,234"],
[1234.56, "JPY", "¥1,235"],
[1234, "AUD", "A$1,234"],
[1234, "CAD", "CA$1,234"],
[1234, "NZD", "NZ$1,234"],
[1234, "CHF", "CHF 1,234"],
[1234, "HKD", "HK$1,234"],
[1234, "SGD", "S$1,234"],
[1234, "ZZZ", "1,234 ZZZ"], // fallback
];

let failures = 0;
for (const [amount, currency, expected] of cases) {
const got = formatMoney(amount, currency);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} formatMoney(${amount}, ${currency}) = ${JSON.stringify(got)}${ok ? "" : ` (expected ${JSON.stringify(expected)})`}`);
if (!ok) failures++;
}

if (failures > 0) {
console.error(`\n${failures} failure(s)`);
process.exit(1);
}
console.log("\nAll cases passed.");
128 changes: 128 additions & 0 deletions smoke/smoke-tooltip.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;font-size:14px;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;padding:20px;">

<!-- Header -->
<tr><td style="padding:20px 24px;background:#0f3460;border-radius:8px 8px 0 0;">
<h1 style="margin:0;font-size:22px;color:#fff;">Richfolio Daily Brief</h1>
<p style="margin:6px 0 0;color:#8a8a9a;font-size:13px;">Tuesday 28 April 2026</p>
</td></tr>

<!-- Portfolio Stats -->
<tr><td style="padding:16px 24px;background:#16213e;border-bottom:1px solid #2a2a4a;">
<table width="100%" cellpadding="0" cellspacing="0"><tr>
<td style="text-align:center;padding:8px;">
<div style="font-size:11px;color:#8a8a9a;text-transform:uppercase;">Holdings Value</div>
<div style="font-size:20px;font-weight:bold;color:#fff;">$6,000</div>
</td>
<td style="text-align:center;padding:8px;">
<div style="font-size:11px;color:#8a8a9a;text-transform:uppercase;">Portfolio Beta</div>
<div style="font-size:20px;font-weight:bold;color:#fff;">1.05</div>
</td>
<td style="text-align:center;padding:8px;">
<div style="font-size:11px;color:#8a8a9a;text-transform:uppercase;">Est. Annual Div</div>
<div style="font-size:20px;font-weight:bold;color:#fff;">$132</div>
</td>
</tr></table>
</td></tr>

<!-- Buy Recommendations (AI or fallback) -->

<tr><td style="padding:20px 24px 8px;background:#16213e;">
<h2 style="margin:0;font-size:16px;color:#3498db;">AI Buy Recommendations</h2>
<p style="margin:4px 0 0;font-size:11px;color:#8a8a9a;">Powered by Gemini — considers fundamentals, valuation, allocation gap, technicals, and news sentiment.</p>
</td></tr>
<tr><td style="padding:0 24px 16px;background:#16213e;">

<div style="padding:10px 0;border-bottom:1px solid #2a2a4a;">
<div style="margin-bottom:4px;">
<span style="font-weight:bold;font-size:14px;color:#fff;" title="McDonald&#39;s Corporation">MCD</span>
&nbsp;<span style="background:#2ecc71;color:#000;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:bold;">STRONG BUY</span><span style="background:#3498db22;color:#3498db;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;margin-left:6px;">Value B</span>
&nbsp;<div style="display:inline-block;width:60px;height:8px;background:#2a2a4a;border-radius:4px;vertical-align:middle;"><div style="width:84%;height:100%;background:#2ecc71;border-radius:4px;"></div></div> <span style="font-size:11px;color:#8a8a9a;">84%</span>
<span style="float:right;font-weight:bold;color:#fff;">$2,500</span>
</div>
<div style="font-size:12px;color:#e0e0e0;margin-top:4px;">McDonald's Corporation is near 52-week lows with P/E below historical average.</div>
<div style="font-size:11px;color:#8a8a9a;margin-top:6px;border-top:1px solid #2a2a4a;padding-top:6px;"><br><span style="color:#2ecc71;">Limit order:</span> $290.00 — Near 50-day MA support</div>

</div>

<div style="margin-top:12px;">
<div style="font-size:11px;color:#8a8a9a;text-transform:uppercase;margin-bottom:6px;">Hold / Wait</div>

<div style="padding:4px 0;font-size:12px;">
<span style="font-weight:bold;" title="Apple Inc.">AAPL</span>
&nbsp;<span style="background:#95a5a6;color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:bold;">HOLD</span><span style="background:#3498db22;color:#3498db;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;margin-left:6px;">Value B</span>
<span style="color:#8a8a9a;margin-left:8px;">Apple Inc. is above target allocation; no entry case.</span>
</div>
</div>
</td></tr>

<!-- Full Allocation Table -->
<tr><td style="padding:20px 24px 8px;background:#16213e;border-top:1px solid #2a2a4a;">
<h2 style="margin:0;font-size:16px;color:#3498db;">Allocation Table</h2>
</td></tr>
<tr><td style="padding:0 24px 16px;background:#16213e;">
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:12px;">
<tr style="color:#8a8a9a;font-size:10px;text-transform:uppercase;">
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;">Ticker</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Price</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Current</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Target</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Gap</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">P/E</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Div</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">Beta</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;">52w Range</td>
</tr>

<tr>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-weight:bold;" title="McDonald&#39;s Corporation">MCD</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">$295.5</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">0.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">5.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;color:#e74c3c;">+5.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">24.1 ✅</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">2.2%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">0.62</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-family:monospace;font-size:11px;">██░░░░░░░░ 18%</td>
</tr>
<tr>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-weight:bold;" title="Apple Inc.">AAPL</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">$200</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">12.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">10.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;color:#f39c12;">-2.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">30.0 ⚠️</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">0.5%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">1.20</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-family:monospace;font-size:11px;">███████░░░ 65%</td>
</tr>
<tr>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-weight:bold;" title="Foo&quot; onmouseover=&quot;alert(1)">EVIL</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">$10</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">0.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">1.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;color:#2ecc71;">+1.0%</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">—</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">—</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;text-align:right;">—</td>
<td style="padding:5px 3px;border-bottom:1px solid #2a2a4a;font-family:monospace;font-size:11px;">—</td>
</tr>
</table>
</td></tr>

<!-- News Digest -->


<!-- Footer -->
<tr><td style="padding:16px 24px;background:#0f3460;border-radius:0 0 8px 8px;text-align:center;">
<p style="margin:0;font-size:11px;color:#8a8a9a;">
Edit CONFIG_JSON variable to update your portfolio · Powered by Richfolio
</p>
</td></tr>

</table>
</body>
</html>
Loading
Loading