Skip to content

Commit 79cb15e

Browse files
committed
Fixed a bunch of things and implemented proper APIs
1 parent de10935 commit 79cb15e

21 files changed

Lines changed: 1251 additions & 282 deletions

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ Fire Tools is provided for **educational and planning purposes only**. The calcu
1616

1717
**Always do your research or consult with a qualified financial advisor before making investment decisions.**
1818

19+
### Market Data Disclaimer
20+
21+
Fire Tools fetches live asset prices and exchange rates from **Yahoo Finance** through a community open-source integration. This data is provided **as an indication only**:
22+
23+
- Prices may be **delayed, incomplete, or inaccurate**
24+
- We take **no responsibility** for wrong or delayed market data
25+
- The Yahoo Finance API is **not officially endorsed** by Yahoo and may **stop working at any time** without notice
26+
- This is a community-discovered endpoint, not a supported commercial API
27+
- **Always verify prices** with your broker or a professional financial data provider before making investment decisions
28+
29+
### Rate Limits
30+
31+
To avoid overloading the Yahoo Finance API, Fire Tools enforces the following limits:
32+
33+
| Limit | Value | Description |
34+
|-------|-------|-------------|
35+
| **Per-request throttle** | 1 second | Minimum delay between consecutive API calls |
36+
| **Daily budget** | 500 requests | Maximum requests per calendar day (resets at midnight) |
37+
38+
When limits are reached, the app gracefully falls back to the last known prices and hardcoded default exchange rates. You can check the current rate-limit status in **Settings → Market Data**.
39+
1940
**[Try it live →](https://mbianchidev.github.io/fire-tools/)**
2041

2142
---

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ function App() {
480480
<footer className="app-footer">
481481
<p>
482482
Fire Tools - Disclaimer: This is for educational purposes only.
483+
Market data is provided as an indication only and may be delayed or inaccurate.
483484
Consult with a financial advisor for professional advice.
484485
</p>
485486
<div className="footer-links">

src/components/AssetAllocationManager.css

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,52 @@
545545
cursor: not-allowed !important;
546546
}
547547

548+
/* Ticker input with fetch button */
549+
.ticker-input-row {
550+
display: flex;
551+
gap: 0.5rem;
552+
align-items: stretch;
553+
}
554+
555+
.ticker-input-row .dialog-input {
556+
flex: 1;
557+
}
558+
559+
.ticker-fetch-btn {
560+
padding: 0.5rem 0.75rem;
561+
border: 2px solid var(--border-subtle);
562+
border-radius: 6px;
563+
background: var(--bg-tertiary);
564+
color: var(--text-primary);
565+
cursor: pointer;
566+
transition: all 0.2s;
567+
display: flex;
568+
align-items: center;
569+
}
570+
571+
.ticker-fetch-btn:hover:not(:disabled) {
572+
border-color: var(--accent-primary);
573+
color: var(--accent-primary);
574+
}
575+
576+
.ticker-fetch-btn:disabled {
577+
opacity: 0.4;
578+
cursor: not-allowed;
579+
}
580+
581+
.ticker-lookup-status {
582+
font-size: 0.8rem;
583+
margin-top: 0.25rem;
584+
}
585+
586+
.ticker-lookup-status.success {
587+
color: var(--success);
588+
}
589+
590+
.ticker-lookup-status.error {
591+
color: var(--error);
592+
}
593+
548594
.dialog-actions {
549595
display: flex;
550596
gap: 1rem;
@@ -2582,3 +2628,79 @@ select:disabled {
25822628
outline: none;
25832629
}
25842630

2631+
/* Price Refresh Row */
2632+
.price-refresh-row {
2633+
display: flex;
2634+
align-items: center;
2635+
gap: 1rem;
2636+
margin-top: 0.75rem;
2637+
flex-wrap: wrap;
2638+
}
2639+
2640+
.price-refresh-row .action-btn {
2641+
padding: 0.5rem 1rem;
2642+
font-size: 0.875rem;
2643+
background: var(--bg-tertiary);
2644+
color: var(--text-primary);
2645+
border: 1px solid var(--border-subtle);
2646+
}
2647+
2648+
.price-refresh-row .action-btn:hover:not(:disabled) {
2649+
background: var(--accent-primary);
2650+
color: #fff;
2651+
border-color: var(--accent-primary);
2652+
}
2653+
2654+
.price-refresh-row .action-btn:disabled {
2655+
opacity: 0.6;
2656+
cursor: not-allowed;
2657+
}
2658+
2659+
.price-refresh-status {
2660+
font-size: 0.8rem;
2661+
color: var(--success);
2662+
}
2663+
2664+
.price-refresh-error {
2665+
font-size: 0.8rem;
2666+
color: var(--error);
2667+
}
2668+
2669+
.price-refresh-rates {
2670+
font-size: 0.8rem;
2671+
color: var(--text-muted);
2672+
display: inline-flex;
2673+
align-items: center;
2674+
gap: 0.25rem;
2675+
}
2676+
2677+
/* Market price delta display in price cell */
2678+
.price-cell {
2679+
display: flex;
2680+
flex-direction: column;
2681+
gap: 0.15rem;
2682+
}
2683+
2684+
.market-price-delta {
2685+
font-size: 0.75rem;
2686+
display: flex;
2687+
gap: 0.35rem;
2688+
align-items: center;
2689+
}
2690+
2691+
.market-price-delta.gain {
2692+
color: var(--success);
2693+
}
2694+
2695+
.market-price-delta.loss {
2696+
color: var(--error);
2697+
}
2698+
2699+
.market-price {
2700+
opacity: 0.8;
2701+
}
2702+
2703+
.delta-pct {
2704+
font-weight: 600;
2705+
}
2706+

src/components/AssetAllocationPage.tsx

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { exportAssetAllocationToCSV, importAssetAllocationFromCSV } from '../uti
1313
import { loadSettings, saveSettings } from '../utils/cookieSettings';
1414
import { getDemoAssetAllocationData } from '../utils/defaults';
1515
import { syncAssetAllocationToNetWorth } from '../utils/dataSync';
16+
import { useAssetPrices } from '../hooks/useAssetPrices';
17+
import { useExchangeRates } from '../hooks/useExchangeRates';
1618
import { MaterialIcon } from './MaterialIcon';
1719
import { EditableAssetClassTable } from './EditableAssetClassTable';
1820
import { AllocationChart } from './AllocationChart';
@@ -55,13 +57,13 @@ export const AssetAllocationPage: React.FC = () => {
5557
const defaultTargets = {
5658
STOCKS: { targetMode: 'PERCENTAGE' as AllocationMode, targetPercent: 60 },
5759
BONDS: { targetMode: 'PERCENTAGE' as AllocationMode, targetPercent: 40 },
58-
CASH: { targetMode: 'SET' as AllocationMode },
59-
CRYPTO: { targetMode: 'PERCENTAGE' as AllocationMode, targetPercent: 0 },
60-
REAL_ESTATE: { targetMode: 'PERCENTAGE' as AllocationMode, targetPercent: 0 },
61-
COMMODITIES: { targetMode: 'PERCENTAGE' as AllocationMode, targetPercent: 0 },
62-
VEHICLE: { targetMode: 'OFF' as AllocationMode },
63-
COLLECTIBLE: { targetMode: 'OFF' as AllocationMode },
64-
ART: { targetMode: 'OFF' as AllocationMode },
60+
CASH: { targetMode: 'SET' as AllocationMode, targetPercent: 0 },
61+
CRYPTO: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
62+
REAL_ESTATE: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
63+
COMMODITIES: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
64+
VEHICLE: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
65+
COLLECTIBLE: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
66+
ART: { targetMode: 'OFF' as AllocationMode, targetPercent: 0 },
6567
};
6668

6769
// Initialize from localStorage if available, otherwise use defaults
@@ -111,6 +113,33 @@ export const AssetAllocationPage: React.FC = () => {
111113

112114
// Track if we're currently syncing to prevent infinite loops
113115
const isSyncingRef = useRef(false);
116+
// Track whether initial price fetch has been done
117+
const hasFetchedPricesRef = useRef(false);
118+
119+
// Yahoo Finance API hooks
120+
const { refreshPrices, isLoading: isPriceLoading, lastRefresh: lastPriceRefresh, error: priceError } = useAssetPrices();
121+
const { refresh: refreshRates, isLoading: isRatesLoading, lastUpdate: ratesLastUpdate, error: ratesError } = useExchangeRates();
122+
123+
// Fetch live prices on mount
124+
useEffect(() => {
125+
if (hasFetchedPricesRef.current) return;
126+
const tickerAssets = assets.filter(a => a.ticker && a.ticker.trim().length > 0);
127+
if (tickerAssets.length === 0) return;
128+
129+
hasFetchedPricesRef.current = true;
130+
refreshPrices(assets).then(({ updatedAssets }) => {
131+
updateAllocation(updatedAssets);
132+
});
133+
// eslint-disable-next-line react-hooks/exhaustive-deps
134+
}, []);
135+
136+
// Manual refresh handler
137+
const handleRefreshPrices = async () => {
138+
const { updatedAssets } = await refreshPrices(assets);
139+
updateAllocation(updatedAssets);
140+
// Also refresh exchange rates
141+
await refreshRates();
142+
};
114143

115144
// Toggle privacy mode and save to settings
116145
const togglePrivacyMode = () => {
@@ -352,6 +381,35 @@ export const AssetAllocationPage: React.FC = () => {
352381
};
353382

354383
const handleAddAsset = (newAsset: Asset) => {
384+
// Check if an asset with the same ticker already exists (merge / "buy more")
385+
const existingAsset = newAsset.ticker
386+
? assets.find(a => a.ticker.toUpperCase() === newAsset.ticker.toUpperCase() && a.assetClass === newAsset.assetClass)
387+
: undefined;
388+
389+
if (existingAsset && existingAsset.shares && newAsset.shares) {
390+
// Weighted average purchase price: (oldShares×oldPrice + newShares×newPrice) / totalShares
391+
const oldShares = existingAsset.shares;
392+
const oldPrice = existingAsset.pricePerShare || 0;
393+
const addShares = newAsset.shares;
394+
const addPrice = newAsset.pricePerShare || 0;
395+
const totalShares = oldShares + addShares;
396+
const avgPrice = totalShares > 0
397+
? (oldShares * oldPrice + addShares * addPrice) / totalShares
398+
: 0;
399+
400+
const mergedAsset: Asset = {
401+
...existingAsset,
402+
shares: totalShares,
403+
pricePerShare: Math.round(avgPrice * 100) / 100,
404+
currentValue: existingAsset.currentValue + newAsset.currentValue,
405+
marketPrice: newAsset.marketPrice ?? existingAsset.marketPrice,
406+
};
407+
408+
const updatedAssets = assets.map(a => a.id === existingAsset.id ? mergedAsset : a);
409+
updateAllocation(updatedAssets);
410+
return;
411+
}
412+
355413
// If the new asset is percentage-based, redistribute existing assets in the same class
356414
if (newAsset.targetMode === 'PERCENTAGE' && newAsset.targetPercent) {
357415
const newAssetPercent = newAsset.targetPercent;
@@ -584,6 +642,36 @@ export const AssetAllocationPage: React.FC = () => {
584642
<div className="portfolio-value-info">
585643
Total holdings (incl. cash): <PrivacyBlur isPrivacyMode={isPrivacyMode}>{formatCurrency(allocation.totalHoldings, currency)}</PrivacyBlur>
586644
</div>
645+
<div className="price-refresh-row">
646+
<button
647+
className="action-btn"
648+
onClick={handleRefreshPrices}
649+
disabled={isPriceLoading || isRatesLoading}
650+
aria-label="Refresh asset prices from Yahoo Finance"
651+
title="Fetch live prices from Yahoo Finance"
652+
>
653+
<MaterialIcon name={isPriceLoading || isRatesLoading ? 'hourglass_empty' : 'refresh'} />
654+
{isPriceLoading || isRatesLoading ? ' Refreshing…' : ' Refresh Prices'}
655+
</button>
656+
{lastPriceRefresh && lastPriceRefresh.updatedCount > 0 && (
657+
<span className="price-refresh-status" role="status">
658+
Updated {lastPriceRefresh.updatedCount} asset{lastPriceRefresh.updatedCount !== 1 ? 's' : ''}
659+
{lastPriceRefresh.failedTickers.length > 0 && (
660+
<> · Failed: {lastPriceRefresh.failedTickers.join(', ')}</>
661+
)}
662+
</span>
663+
)}
664+
{(priceError || ratesError) && (
665+
<span className="price-refresh-error" role="alert">
666+
{priceError || ratesError}
667+
</span>
668+
)}
669+
{ratesLastUpdate && !isRatesLoading && (
670+
<span className="price-refresh-rates" title="Exchange rates last updated">
671+
<MaterialIcon name="currency_exchange" size="small" /> Rates: {new Date(ratesLastUpdate).toLocaleDateString()}
672+
</span>
673+
)}
674+
</div>
587675
</section>
588676

589677
{/* How to Use - Collapsible at top */}
@@ -751,6 +839,7 @@ export const AssetAllocationPage: React.FC = () => {
751839
onClose={() => setIsDialogOpen(false)}
752840
onSubmit={handleAddAsset}
753841
defaultCurrency={currency as 'EUR' | 'USD'}
842+
existingAssets={assets}
754843
/>
755844

756845
<MassEditDialog

src/components/CollapsibleAllocationTable.tsx

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -233,17 +233,6 @@ export const CollapsibleAllocationTable: React.FC<CollapsibleAllocationTableProp
233233
});
234234
};
235235

236-
// Handle pricePerShare change and update currentValue
237-
const handleEditPricePerShareChange = (value: number) => {
238-
const sharesNum = editValues.shares || 0;
239-
const newCurrentValue = value > 0 && sharesNum > 0 ? sharesNum * value : editValues.currentValue;
240-
setEditValues({
241-
...editValues,
242-
pricePerShare: value,
243-
currentValue: newCurrentValue,
244-
});
245-
};
246-
247236
// Handle currentValue change and update pricePerShare if shares exists
248237
const handleEditCurrentValueChange = (value: number) => {
249238
const sharesNum = editValues.shares || 0;
@@ -609,14 +598,21 @@ export const CollapsibleAllocationTable: React.FC<CollapsibleAllocationTableProp
609598
<td className="currency-value">
610599
{isCashAsset || isValueOnlyAsset ? (
611600
'-'
612-
) : isEditing ? (
613-
<NumberInput
614-
value={editValues.pricePerShare || 0}
615-
onChange={handleEditPricePerShareChange}
616-
className="edit-input"
617-
/>
618601
) : asset.pricePerShare ? (
619-
<PrivacyBlur isPrivacyMode={isPrivacyMode}>{formatCurrency(asset.pricePerShare, currency)}</PrivacyBlur>
602+
<div className="price-cell">
603+
<PrivacyBlur isPrivacyMode={isPrivacyMode}>{formatCurrency(asset.pricePerShare, currency)}</PrivacyBlur>
604+
{asset.marketPrice && asset.marketPrice !== asset.pricePerShare && (
605+
<div className={`market-price-delta ${asset.marketPrice > asset.pricePerShare ? 'gain' : 'loss'}`}>
606+
<PrivacyBlur isPrivacyMode={isPrivacyMode}>
607+
<span className="market-price">{formatCurrency(asset.marketPrice, currency)}</span>
608+
</PrivacyBlur>
609+
<span className="delta-pct">
610+
{((asset.marketPrice - asset.pricePerShare) / asset.pricePerShare * 100) > 0 ? '+' : ''}
611+
{((asset.marketPrice - asset.pricePerShare) / asset.pricePerShare * 100).toFixed(1)}%
612+
</span>
613+
</div>
614+
)}
615+
</div>
620616
) : (
621617
'-'
622618
)}

0 commit comments

Comments
 (0)