Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:312aacc2f42215e3ffe54763edfbe29f7affc0f2ae41d522eaebbe359f2b2ffa
export class AppError extends Error {
constructor(key, params = null, fallback = null) {
super(fallback || key);
this.name = 'AppError';
this.key = key;
this.params = params;
}
}
export const isAppError = (e) => !!e && typeof e === 'object' && typeof e.key === 'string';
export function fail(key, params = null, fallback = null) {
throw new AppError(key, params, fallback);
}
export function errorPayload(e) {
const uit = { error: (e && e.message) || String(e) };
if (isAppError(e)) {
uit.key = e.key;
if (e.params) uit.params = e.params;
}
return uit;
}
+871
View File
@@ -0,0 +1,871 @@
// Generated, do not edit. sha256:79db5d8cc5706ad987b207d98e1c67ca389284a24121f65b199acbe48bce76ec
const GRAMS_PER_TROY_OUNCE = 31.1034768;
export const CONFIG = Object.freeze({
PRICE_SOURCES: Object.freeze({
MEMPOOL_HISTORICAL: '/api/v1/historical-price',
MEMPOOL_PRICES: '/api/v1/prices',
MEMPOOL_PUBLIC_BASE: 'https://mempool.space',
BITVAVO_BASE: 'https://api.bitvavo.com',
BITVAVO_CANDLES: '/v2/{market}/candles',
BITVAVO_TICKER: '/v2/ticker/price',
BITVAVO_ACCOUNT: '/v2/account',
COINGECKO_BASE: 'https://api.coingecko.com/api/v3',
COINGECKO_PRICE: '/simple/price',
COINGECKO_CHART: '/coins/{id}/market_chart',
COINGECKO_PING: '/ping',
GOLDAPI_BASE: 'https://www.goldapi.io',
GOLDAPI_SPOT: '/api/{metal}/{currency}',
GOLDAPI_HISTORICAL: '/api/{metal}/{currency}/{date}',
EULERPOOL_BASE: 'https://api.eulerpool.com',
EULERPOOL_QUOTES: '/api/1/{external_id}/quotes/{symbol}',
METALSDEV_BASE: 'https://api.metals.dev',
METALSDEV_SPOT: '/v1/metal/spot',
METALSDEV_TIMESERIES: '/v1/timeseries',
FRANKFURTER_BASE: 'https://api.frankfurter.dev',
FRANKFURTER_LATEST: '/v1/latest',
FRANKFURTER_SERIES: '/v1/1999-01-04..',
}),
CHART_LIBS: Object.freeze({
CHARTJS: 'vendor/chartjs/chart.umd.js',
HAMMER: 'vendor/hammerjs/hammer.min.js',
ZOOM: 'vendor/chartjs-plugin-zoom/chartjs-plugin-zoom.min.js',
}),
SCHEMA_VERSION: 128,
MAX_ASSET_DECIMALS: 9,
LOCALE_TAGS: Object.freeze({ nl: 'nl-NL', en: 'en-US' }),
INSTALLATION: Object.freeze({ SETTINGS_FILE_NAME: 'installation.json', DEFAULT_LOCALE: 'en' }),
METALS: Object.freeze({
BASE_UNIT: 'g',
BASE_DECIMALS: 7,
GRAMS_PER_TROY_OUNCE,
DISPLAY_DECIMALS: 4,
UNITS: Object.freeze({ g: 1, kg: 1000, ozt: GRAMS_PER_TROY_OUNCE }),
SPOT_DEVIATION_WARN: 0.10,
}),
DB_TABLES: Object.freeze([
'Asset', 'Account', 'Event', 'Valuation', 'Label',
'WalletSource', 'WalletAddress', 'WalletTransaction', 'ScanLog',
'ReconcileProposal', 'ImportBatch', 'PriceHistory', 'WalletTransactionEventLink',
'InterestSchedule', 'InterestPeriod', 'InterestCredit', 'Notification',
'MetalProduct',
'Provider', 'ProviderAuthenticationHmac', 'ProviderEndpoint', 'ProviderFixedRate', 'ProviderCallLimit',
'ProviderInputMap', 'ProviderOutputMap', 'ProviderCoverage', 'ApiCallMetric',
'PriceSource', 'Connection',
'DashboardProfile', 'DashboardWidget',
'AiToolCall', 'AiToolCallMessage', 'BusSignal',
'Command', 'AiProposal',
'Preference', 'Schema', 'ChangeLog', 'HygieneAcceptance', 'IgnoredPriceGap', 'AssetNote',
'Portfolio', 'PriceAbsence', 'IntegrationCall',
]),
DB_DELETABLE_TABLES: Object.freeze([
'ImportBatch', 'PriceHistory', 'ReconcileProposal', 'WalletTransactionEventLink',
'Valuation', 'WalletAddress', 'InterestCredit', 'Notification',
'PriceSource', 'ProviderCoverage', 'Preference', 'BusSignal',
'PriceAbsence',
'IntegrationCall',
]),
DB_HISTORY: Object.freeze({
TRACKED: Object.freeze([
'Account', 'Asset', 'Event', 'Valuation', 'Label', 'MetalProduct',
'InterestSchedule', 'InterestPeriod', 'InterestCredit',
'WalletSource', 'Connection',
]),
UNTRACKED: Object.freeze({
AiToolCall: 'logboek', AiToolCallMessage: 'logboek', BusSignal: 'logboek',
Command: 'logboek', ScanLog: 'logboek', ApiCallMetric: 'logboek', Notification: 'logboek',
WalletTransaction: 'scanketen', WalletAddress: 'scanketen', WalletTransactionEventLink: 'scanketen',
PriceHistory: 'volume', PriceAbsence: 'volume',
AiProposal: 'herkomst', ReconcileProposal: 'herkomst', ImportBatch: 'herkomst',
Provider: 'configuratie', ProviderAuthenticationHmac: 'configuratie',
ProviderEndpoint: 'configuratie', ProviderFixedRate: 'configuratie', ProviderCallLimit: 'configuratie',
ProviderInputMap: 'configuratie', ProviderOutputMap: 'configuratie',
ProviderCoverage: 'configuratie', PriceSource: 'configuratie',
DashboardProfile: 'weergave', DashboardWidget: 'weergave', Preference: 'weergave',
Schema: 'systeem', Portfolio: 'systeem',
ChangeLog: 'logboek',
IntegrationCall: 'logboek',
HygieneAcceptance: 'weergave',
IgnoredPriceGap: 'weergave',
AssetNote: 'weergave',
}),
}),
PEILDATUM: Object.freeze({
MONTH: 1, DAY: 1, HOUR: 0, MINUTE: 0,
TIMEZONE: 'Europe/Amsterdam',
}),
START_YEAR: Object.freeze({
AUTO: 0,
MIN: 1900,
MAX: 2200,
}),
WEALTH_RETURN: Object.freeze({ DUST_SHARE: 0.005, REOPEN_MULTIPLE: 10, ROLLING_DAYS: Object.freeze([1, 7, 30, 365]) }),
AUTO_BLUR: Object.freeze({
PRESETS_MIN: Object.freeze([5, 15, 30]),
NEVER_MIN: 525600,
}),
GLOW_TEMPO: Object.freeze({
brisk: Object.freeze({ MOVE_S: 11, PAUSE_MIN_S: 18, PAUSE_MAX_S: 40, ALWAYS_MULT: 1 }),
calm: Object.freeze({ MOVE_S: 24, PAUSE_MIN_S: 45, PAUSE_MAX_S: 90, ALWAYS_MULT: 2 }),
slow: Object.freeze({ MOVE_S: 36, PAUSE_MIN_S: 80, PAUSE_MAX_S: 140, ALWAYS_MULT: 3 }),
}),
GLOW_CORE_CHANCE: 0.35,
ATTENTION_RING: Object.freeze({ ROUND_MS: 3500, ROUNDS: 3 }),
TIMERS: Object.freeze({
OFF_SECONDS: 31536000,
SPOT_REFRESH: Object.freeze({
MIN_SECONDS: 60,
MAX_SECONDS: 3600,
PRESETS_SECONDS: Object.freeze([60, 300, 900]),
}),
BUS_POLL: Object.freeze({
MIN_SECONDS: 5,
MAX_SECONDS: 300,
PRESETS_SECONDS: Object.freeze([5, 15, 60]),
}),
}),
PRICE_HISTORY: Object.freeze({
NEAREST_MAX_SECONDS: 60,
CARRY_FORWARD_DAYS: Object.freeze({ crypto: 7, metal: 14, fiat: null }),
CLOSED_MARKET_DAYS: Object.freeze({ crypto: 0, metal: 4, fiat: 4 }),
WEEKEND_PRICE: Object.freeze({ crypto: true, metal: false, fiat: false }),
RECENT_CLOSE_DAYS: 14,
FX_MAX_AGE_DAYS: 7,
}),
API_USAGE: Object.freeze({ WARN_SHARE: 0.8 }),
HYGIENE: Object.freeze({
PRICE_GAP_DAYS: 7,
MAX_ROWS_PER_FINDING: 200,
OUTLIER_FACTOR: 3,
TROY_OUNCE_TOLERANCE: 0.2,
SOURCES_DISAGREE_SHARE: 0.08,
BOOKING_PRICE_DEVIATION: 0.2,
}),
BUS: Object.freeze({
RETENTION_HOURS: 24,
}),
INTEGRATION_LOG: Object.freeze({
RETENTION_DAYS: 90,
MAX_ROWS: 10_000,
DETAIL_MAX_CHARS: 300,
WINDOW_DAYS: 7,
AI_MAX_ROWS: 20,
}),
COMMANDS: Object.freeze({
CLAIM_SECONDS: 20,
COUNTDOWN_SECONDS: 5,
NOTICE_SECONDS: 3,
RETENTION_HOURS: 24,
}),
BACKUP: Object.freeze({
REMIND_AFTER_DAYS: 14,
}),
HEALTH_CHECK: Object.freeze({
REMIND_AFTER_DAYS: 30,
}),
AUTH: Object.freeze({
SESSION_TTL_MS: 12 * 60 * 60 * 1000,
COOKIE_NAME: 'assetapp_session',
LOGIN_PATH: '/login',
LOGIN_ATTEMPTS: Object.freeze({
FREE: 3,
BASE_LOCK_MS: 2000,
MAX_LOCK_MS: 5 * 60 * 1000,
}),
}),
ATTENTION: Object.freeze({
FADE_AFTER_DAYS: 1,
HIDE_AFTER_DAYS: 2,
}),
FOCUS: Object.freeze({
INTEREST_ACCOUNT: 'interest.focusAccount',
ROW: 'focus.row',
ROW_TTL_MS: 30_000,
}),
LICENSE: Object.freeze({
FILE_NAME: 'license.json',
FREE_FILE_NAME: 'free-license.json',
BUYER_FILE_NAME: 'buyer.json',
SUPPORT: Object.freeze({
ATTACHMENT_TYPES: Object.freeze(['image/png', 'image/jpeg', 'image/webp']),
MAX_ATTACHMENT_BYTES: 2 * 1024 * 1024,
RECIPIENT: '72304ea4d273d6c685aaa76342975e7a001190c9f15369ed3d3b303b79c8c74c',
RECIPIENT_NAME: 'Atreides Assets',
RELAYS: Object.freeze(['wss://nos.lol']),
MAX_TEXT: 4000,
MAX_EVENT_BYTES: 64 * 1024,
PUBLISH_TIMEOUT_MS: 10_000,
}),
INFO_LINKS: Object.freeze([
Object.freeze({ id: 'nostrHow', url: 'https://nostr.how/' }),
Object.freeze({ id: 'nostrOrg', url: 'https://nostr.org/' }),
Object.freeze({ id: 'alby', url: 'https://getalby.com/alby-extension' }),
Object.freeze({ id: 'nos2x', url: 'https://github.com/fiatjaf/nos2x' }),
Object.freeze({ id: 'lightning', url: 'https://lightning.network/' }),
]),
PAY_APPS: Object.freeze([
Object.freeze({ id: 'phoenix', name: 'Phoenix', group: 'easy', url: 'https://phoenix.acinq.co/' }),
Object.freeze({ id: 'pocket', name: 'Pocket', group: 'easy', url: 'https://pocketbitcoin.com/lightning' }),
Object.freeze({ id: 'strike', name: 'Strike', group: 'other', url: 'https://strike.me/' }),
Object.freeze({ id: 'zeus', name: 'Zeus', group: 'other', url: 'https://zeusln.com/' }),
Object.freeze({ id: 'bringin', name: 'Bringin', group: 'other', url: 'https://bringin.app/' }),
]),
FEATURE_IMAGES: 'img/features',
FEATURE_GROUPS: Object.freeze(['overview', 'records', 'prices', 'app', 'bitcoin']),
FEATURES: Object.freeze([
{ id: 'dashboard', group: 'overview', free: null }, { id: 'wealth', group: 'overview', free: null },
{ id: 'returns', group: 'overview', free: null }, { id: 'taxes', group: 'overview', has: 'tax' },
{ id: 'balances', group: 'records', free: null }, { id: 'interest', group: 'records', free: null },
{ id: 'import', group: 'records', has: 'import' }, { id: 'data', group: 'records', has: 'data' },
{ id: 'health', group: 'records', has: 'health' }, { id: 'metals', group: 'records', free: false },
{ id: 'priceHistory', group: 'prices', free: null },
{ id: 'sourceChoice', group: 'prices', has: 'sourceMatrix' }, { id: 'providerKeys', group: 'prices', has: 'providerKeys' },
{ id: 'portfolios', group: 'app', has: 'portfolios' }, { id: 'ai', group: 'app', has: 'ai' },
{ id: 'kiosk', group: 'app', has: 'kiosk' },
{ id: 'tracking', group: 'bitcoin', free: null }, { id: 'lightning', group: 'bitcoin', has: 'lightning' },
{ id: 'ownMempool', group: 'bitcoin', has: 'ownMempool' }, { id: 'signals', group: 'bitcoin', has: 'signals' },
{ id: 'netshare', group: 'bitcoin', has: 'netshare' },
].map(f => Object.freeze(f))),
SELLER_KEY: 'cfc6e67daaf080abbdcbacdbca587d54a3c18b119f2f05d7b087b859caa96a2f',
FORMAT: 1,
FORMATS: Object.freeze([1, 2]),
MESSAGE_PREFIX: 'assetapp-license-v1',
ISSUER_PREFIX: 'assetapp-issuer-v1',
UNLIMITED: 'unlimited',
LEVELS: Object.freeze(['full', 'free']),
FREE: Object.freeze({
MAX: Object.freeze({
assets: 1,
fiatAccounts: 1,
portfolios: 1,
valuations: 1,
}),
ASSETS: Object.freeze(['BTC']),
HAS: Object.freeze({
ai: true,
sourceMatrix: false,
lightning: false,
tax: false,
providerKeys: false,
ownMempool: false,
signals: false,
netshare: false,
portfolios: false,
import: false,
data: false,
health: false,
kiosk: false,
}),
}),
}),
EDITION: Object.freeze({
CLIENT: Object.freeze({
entry: 'client/edition.js',
starts: Object.freeze(['client/app.js', 'client/boot-gate.js', 'client/login.js']),
}),
SERVER: Object.freeze({
entry: 'server/edition.mjs',
starts: Object.freeze(['server/index.mjs', 'server/supervisor.mjs', 'server/mcp/stdio.mjs']),
}),
}),
PORTFOLIO: Object.freeze({
RESTART_EXIT_CODE: 75,
DEFAULT_BASE_CURRENCY: 'EUR',
BASE_CURRENCIES: Object.freeze([
'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'IDR', 'ILS', 'INR', 'ISK',
'JPY', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD', 'PHP', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'USD', 'ZAR',
]),
ACTIVE_KEY: 'portfolio_active',
SWITCH_KEY: 'portfolio_switching',
}),
SPLASH: Object.freeze({
MIN_MS: 3200,
FADE_MS: 900,
QUOTE_START_MS: 1800,
QUOTE_CHAR_MS: 40,
QUOTE_WORD_PAUSE_MS: 110,
}),
CONNECTIONS: Object.freeze({
TEST_TIMEOUT_MS: 8000,
RESULT_HOLD_MS: 6000,
RESULT_FADE_MS: 600,
}),
NWC: Object.freeze({
REQUEST_TIMEOUT_MS: 15000,
CLIENT_TIMEOUT_MS: 20000,
}),
LND: Object.freeze({
REQUEST_TIMEOUT_MS: 15000,
CLIENT_TIMEOUT_MS: 20000,
CHANNEL_LABEL: 'Lightning-kanaal',
}),
MARKET_SERVICE: Object.freeze({
SETTINGS_FILE_NAME: 'market-service.json',
URL: 'https://atreides.kamenier-hamer.nl',
APP_NAME: 'Atreides Services',
REQUEST_TIMEOUT_MS: 10000,
CLIENT_TIMEOUT_MS: 15000,
REGISTER_TTL_MS: 10 * 60_000,
MAX_SERIES_PAGES: 10,
INTRADAY_MAX_DAYS: 7,
MOMENT_WINDOW_MS: 10 * 60_000,
HISTORY_MAX_DAYS: 3650,
PROVIDER_KEY: 'atreides_services',
SPOT_TTL_MS: 30_000,
RETRY_PAUSE_MS: 5 * 60_000,
MAX_INSTRUMENTS_PER_SPOT: 100,
}),
AI: Object.freeze({
SERVER_NAME: 'assetapp',
SERVER_VERSION: '3',
PROTOCOL_VERSION: '2025-06-18',
AUDIT_PAGE_SIZE: 100,
PROPOSAL_PAGE_SIZE: 50,
DESTRUCTIVE_PROPOSAL_KINDS: Object.freeze(['account_delete']),
FIELD_CLASSES: Object.freeze(['plain', 'rate', 'quantityFiat', 'quantity', 'handle', 'chain', 'drop']),
BUCKET_LADDERS: Object.freeze({
COARSE: Object.freeze([1, 1.5, 2, 3, 5, 7]),
FINE: Object.freeze([1, 1.25, 1.6, 2, 2.5, 3.15, 4, 5, 6.3, 8]),
}),
BUCKET_MIN_VALUES: Object.freeze({ COARSE: 1000, FINE: 0 }),
SHARE_BUCKETS: Object.freeze([2, 4, 6, 8, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75,
80, 85, 90, 95, 100]),
SHARED_PREF_SCOPES: Object.freeze(['app', 'timers']),
PROPOSABLE_PREF_SCOPES: Object.freeze(['app', 'timers']),
LIMITS: Object.freeze({
MAX_ROWS_PER_LIST: 50,
MAX_TEXT_CHARS: 10000,
MAX_LOG_CHARS: 10000,
METRICS_WINDOW_HOURS: 24,
}),
}),
ROW_GUARD: false,
});
export const AUTH_KINDS = Object.freeze({
none: Object.freeze({ secret_fields: Object.freeze([]) }),
header_key: Object.freeze({ secret_fields: Object.freeze(['api_key']) }),
query_key: Object.freeze({ secret_fields: Object.freeze(['api_key']) }),
bearer: Object.freeze({ secret_fields: Object.freeze(['api_key']) }),
basic: Object.freeze({ secret_fields: Object.freeze(['api_key', 'api_secret']) }),
hmac: Object.freeze({ secret_fields: Object.freeze(['api_key', 'api_secret']) }),
});
export const ENUMS = Object.freeze({
ASSET_KIND: Object.freeze(['crypto', 'metal', 'fiat']),
ASSET_DISPLAY_UNIT: Object.freeze(['unit', 'subunit']),
METAL_FORM: Object.freeze(['coin', 'bar', 'other']),
ACCOUNT_KIND: Object.freeze(['exchange', 'wallet', 'lightning', 'card', 'bank', 'vault']),
TAX_CATEGORY: Object.freeze(['bank', 'overig']),
TAX_SCOPE: Object.freeze(['all', 'category', 'none']),
WEALTH_SCOPE: Object.freeze(['managed', 'none']),
EVENT_TYPE: Object.freeze(['buy', 'sell', 'transfer', 'interest', 'reservation']),
LEDGER_EVENT_TYPE: Object.freeze(['buy', 'sell', 'transfer', 'interest']),
EVENT_SOURCE: Object.freeze(['manual', 'import', 'reconcile', 'lightning']),
EVENT_PRICE_ORIGIN: Object.freeze(['paid', 'market_day', 'market_moment', 'none']),
VALUATION_SOURCE: Object.freeze(['onchain', 'manual', 'statement']),
CHANGE_OPERATION: Object.freeze(['update', 'delete']),
CHANGE_ORIGIN: Object.freeze(['manual', 'import', 'reconcile', 'interest', 'lightning',
'ai_proposal', 'merge', 'system']),
INTEREST_CADENCE: Object.freeze(['term_end', 'annual', 'quarterly', 'monthly', 'weekly']),
INTEREST_RATE_KIND: Object.freeze(['fixed', 'variable']),
INTEREST_CREDIT_STATUS: Object.freeze(['due', 'booked', 'dismissed']),
WALLET_SOURCE_KIND: Object.freeze(['xpub', 'address', 'nwc', 'lnd']),
ONCHAIN_SOURCE_KIND: Object.freeze(['xpub', 'address']),
SCRIPT_TYPE: Object.freeze(['p2tr', 'p2wpkh', 'p2sh-p2wpkh', 'p2pkh']),
SCAN_STATUS: Object.freeze(['success', 'partial', 'failed']),
RECONCILE_DIRECTION: Object.freeze(['in', 'out']),
WALLET_TX_LAYER: Object.freeze(['onchain', 'lightning']),
WALLET_TX_KIND: Object.freeze(['channel', 'self']),
RECONCILE_SUGGESTED: Object.freeze(['trade', 'transfer']),
RECONCILE_STATUS: Object.freeze(['pending', 'accepted_trade', 'accepted_transfer', 'accepted_interest', 'ignored', 'auto_linked']),
LINK_METHOD: Object.freeze(['exact', 'soft', 'manual']),
NOTIFICATION_KIND: Object.freeze(['reconcile', 'interest', 'valuation', 'schedule_ended', 'source_proposal', 'ai_proposal', 'reservation']),
CONNECTION_KIND: Object.freeze(['btc_explorer', 'lnd', 'nwc']),
THEME: Object.freeze(['dark', 'light']),
ACCENT: Object.freeze(['orange', 'magenta', 'lila', 'indigo', 'blue', 'cyan', 'teal', 'slate', 'sand']),
DEPTH: Object.freeze(['flat', 'deep']),
PROFILE: Object.freeze(['business', 'glass']),
GLOW: Object.freeze(['off', 'on']),
GLOW_SHAPE: Object.freeze(['blobs', 'bands']),
MOTION: Object.freeze(['off', 'step', 'always']),
GLOW_TEMPO: Object.freeze(['brisk', 'calm', 'slow']),
ROW_SELECTION: Object.freeze(['light', 'tint', 'pill']),
GLASS_RENDER: Object.freeze(['real', 'mimic']),
MWR_METHOD: Object.freeze(['dietz', 'irr']),
PERSONA: Object.freeze(['bitcoin', 'broad']),
MARKET_SPOT: Object.freeze(['off', 'fallback', 'first']),
REPEAT_ENTRY_KEY: Object.freeze(['ctrl_enter', 'enter']),
RESULT_SIGNS: Object.freeze(['none', 'negative', 'both']),
PRICE_RESOLUTION: Object.freeze(['day', 'timestamp']),
CONNECTION_STATUS: Object.freeze(['ok', 'fail']),
PRICE_SOURCE_STATUS: Object.freeze(['proposed', 'confirmed', 'manual']),
HMAC_HASH_ALGORITHM: Object.freeze(['SHA-256']),
HMAC_ENCODING: Object.freeze(['hex', 'base64']),
AI_CALL_OUTCOME: Object.freeze(['ok', 'error', 'refused']),
INTEGRATION_TARGET: Object.freeze(['service', 'provider']),
INTEGRATION_OUTCOME: Object.freeze(['fail', 'empty', 'refused']),
AI_MESSAGE_KIND: Object.freeze(['request', 'response']),
AI_SCOPE: Object.freeze(['none', 'read', 'write', 'control']),
COMMAND_KIND: Object.freeze(['navigate', 'locate']),
AI_PROPOSAL_KIND: Object.freeze(['price_point', 'account', 'event', 'account_delete', 'valuation', 'portfolio', 'preference', 'source_choice']),
SOURCE_CHOICE_ACTION: Object.freeze(['first', 'remove']),
QUOTE_UNIT: Object.freeze(['ozt']),
AI_PROPOSAL_STATUS: Object.freeze(['pending', 'accepted', 'rejected']),
COMMAND_STATE: Object.freeze(['open', 'claimed', 'done', 'rejected', 'expired']),
});
export const SYSTEM_PAGE = Object.freeze({
ROUTE: 'system',
TABS: Object.freeze({
PORTFOLIOS: 'portfolios',
SETTINGS: 'settings',
ASSETS: 'assets',
ACCOUNTS: 'accounts',
INTEGRATIONS: 'integrations',
IMPORT: 'import',
DATA: 'data',
HEALTH: 'health',
AI: 'ai',
LICENSE: 'license',
}),
});
export const EVENTS_PAGE = Object.freeze({
ROUTE: 'events',
TABS: Object.freeze({
ACCOUNTS: 'accounts',
VALUATIONS: 'valuations',
TRADES: 'trades',
TRANSFERS: 'transfers',
INTEREST: 'interest',
RESERVATIONS: 'reservations',
}),
});
export const CHARTS_PAGE = Object.freeze({
ROUTE: 'charts',
TABS: Object.freeze({
PRICE: 'price',
POWERLAW: 'pl',
WMA: 'wma',
MAYER: 'mayer',
}),
});
export const ROUTE_KEYS = Object.freeze({
HOME: 'home',
ACTIONS: 'actions',
EVENTS: EVENTS_PAGE.ROUTE,
WEALTH: 'wealth',
TAXES: 'taxes',
VALIDATION: 'validation',
TRACKING: 'tracking',
CONVERSION_BITCOIN: 'conversion/bitcoin',
PRICE_HISTORY: 'price-history',
CHARTS: CHARTS_PAGE.ROUTE,
SYSTEM: SYSTEM_PAGE.ROUTE,
GUIDE: 'guide',
});
const doel = (route, tab, sub) => Object.freeze({ route, tab: tab ?? null, sub: sub ?? null, element: null });
export const TARGETS = Object.freeze({
HOME: doel(ROUTE_KEYS.HOME),
ACTIONS: doel(ROUTE_KEYS.ACTIONS),
CHARTS: doel(ROUTE_KEYS.CHARTS),
TAXES: doel(ROUTE_KEYS.TAXES),
WEALTH: doel(ROUTE_KEYS.WEALTH),
TRACKING: doel(ROUTE_KEYS.TRACKING),
PRICE_HISTORY: doel(ROUTE_KEYS.PRICE_HISTORY),
CONVERSION: doel(ROUTE_KEYS.CONVERSION_BITCOIN),
GUIDE: doel(ROUTE_KEYS.GUIDE),
PORTFOLIOS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.PORTFOLIOS),
ASSETS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.ASSETS),
ACCOUNTS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.ACCOUNTS),
INTEGRATIONS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS),
AI_ACTIVITY: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.AI),
SETTINGS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.SETTINGS),
LICENSE: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.LICENSE),
IMPORT: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.IMPORT),
DATA: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.DATA),
HEALTH: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.HEALTH),
PROVIDER_KEYS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'providers'),
SOURCE_PROPOSALS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'sources'),
CONNECTIONS: doel(ROUTE_KEYS.SYSTEM, SYSTEM_PAGE.TABS.INTEGRATIONS, 'connections'),
CHART_PRICE: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.PRICE),
CHART_POWERLAW: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.POWERLAW),
CHART_WMA: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.WMA),
CHART_MAYER: doel(ROUTE_KEYS.CHARTS, CHARTS_PAGE.TABS.MAYER),
EVENT_ACCOUNTS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.ACCOUNTS),
VALUATIONS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.VALUATIONS),
TRADES: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.TRADES),
TRANSFERS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.TRANSFERS),
INTEREST: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.INTEREST),
RESERVATIONS: doel(ROUTE_KEYS.EVENTS, EVENTS_PAGE.TABS.RESERVATIONS),
});
export const NOTIFICATION_TARGETS = Object.freeze({
reconcile: TARGETS.ACTIONS,
interest: TARGETS.ACTIONS,
valuation: TARGETS.ACTIONS,
schedule_ended: TARGETS.ACCOUNTS,
source_proposal: TARGETS.SOURCE_PROPOSALS,
ai_proposal: TARGETS.ACTIONS,
reservation: TARGETS.ACTIONS,
});
export const NOTIFICATION_PRIORITY = Object.freeze([
'reconcile', 'valuation', 'reservation', 'interest', 'ai_proposal', 'source_proposal', 'schedule_ended',
]);
export const PLACE_LABELS = Object.freeze({
providers: 'integrations.tabProviders',
sources: 'integrations.tabSources',
connections: 'integrations.tabConnections',
[CHARTS_PAGE.TABS.PRICE]: 'signals.tabPrice',
[CHARTS_PAGE.TABS.POWERLAW]: 'signals.tabPl',
[CHARTS_PAGE.TABS.WMA]: 'signals.tabWma',
[CHARTS_PAGE.TABS.MAYER]: 'signals.tabMayer',
});
export function targetHash(target) {
if (!target || !target.route) throw new Error('A target without a route has no address.');
return `#/${[target.route, target.tab, target.sub].filter(Boolean).join('/')}`;
}
export function hashFromLocation(hash) {
const pad = String(hash || '').replace(/^#\/?/, '');
return pad ? `#/${pad}` : targetHash(TARGETS.HOME);
}
export function commandTooLate(expiresAt, nu = Date.now()) {
if (!expiresAt) return false;
const einde = Date.parse(expiresAt);
if (Number.isNaN(einde)) return false;
return einde - nu < CONFIG.COMMANDS.COUNTDOWN_SECONDS * 1000;
}
export function targetTab(target) {
if (!target || !target.tab) return null;
return [target.tab, target.sub].filter(Boolean).join('/');
}
export const CAPABILITIES = Object.freeze(['spot', 'daily_history', 'history_point']);
export const SOURCE_CAPABILITIES = Object.freeze(['spot', 'history']);
export const SOURCE_ENDPOINTS = Object.freeze({
spot: Object.freeze(['spot']),
history: Object.freeze(['daily_history', 'history_point']),
});
export function sourceCapabilityOf(capability) {
return SOURCE_CAPABILITIES.find(c => SOURCE_ENDPOINTS[c].includes(capability)) ?? null;
}
export const OUTPUT_FIELDS = Object.freeze(['time', 'price']);
export const TRANSFORMS = Object.freeze(['none', 'ms_to_s', 'to_number', 'last_per_day', 'troy_ounce_to_gram', 'iso_date_to_s', 'ms_to_s_local_close']);
export const HTTP_METHODS = Object.freeze(['GET', 'POST']);
export const ACCOUNT_SOURCE_KINDS = Object.freeze({
exchange: Object.freeze([]),
wallet: Object.freeze(['xpub', 'address']),
lightning: Object.freeze(['xpub', 'address', 'nwc', 'lnd']),
card: Object.freeze([]),
bank: Object.freeze([]),
vault: Object.freeze([]),
});
export const ACCOUNT_INTEREST_KINDS = Object.freeze(['exchange', 'card', 'bank']);
export const ACCOUNT_ASSET_KINDS = Object.freeze({
exchange: Object.freeze(['crypto', 'fiat']),
wallet: Object.freeze(['crypto']),
lightning: Object.freeze(['crypto']),
card: Object.freeze(['fiat']),
bank: Object.freeze(['fiat']),
vault: Object.freeze(['metal']),
});
export const TAX_CATEGORY_BY_ASSET_KIND = Object.freeze({ crypto: 'overig', metal: 'overig', fiat: 'bank' });
export const BALANCE_CAPTURE_KINDS = Object.freeze(['fiat']);
export function balanceCaptureOn(asset) {
if (!asset) return false;
if (asset.balanceCapture === 0 || asset.balanceCapture === 1) return asset.balanceCapture === 1;
return BALANCE_CAPTURE_KINDS.includes(asset.kind);
}
export const CONNECTION_PROVIDER = Object.freeze({ btc_explorer: 'mempool' });
export const ASSET_DEFINITIONS = Object.freeze({
BTC: Object.freeze({ symbol: 'BTC', name: 'Bitcoin', kind: 'crypto', unit: 'BTC', subunit: 'sat', decimals: 8, displayUnit: 'subunit' }),
ETH: Object.freeze({ symbol: 'ETH', name: 'Ethereum', kind: 'crypto', unit: 'ETH', subunit: null, decimals: 8, displayUnit: 'unit' }),
XAU: Object.freeze({ symbol: 'XAU', name: 'Goud', kind: 'metal', unit: 'ozt', subunit: 'g', decimals: 7, displayUnit: 'unit' }),
XAG: Object.freeze({ symbol: 'XAG', name: 'Zilver', kind: 'metal', unit: 'ozt', subunit: 'g', decimals: 7, displayUnit: 'unit' }),
});
export const PREFERENCES = Object.freeze({
app: Object.freeze({
theme: Object.freeze({ type: 'enum', allowed: ENUMS.THEME, initial: 'dark' }),
accent: Object.freeze({ type: 'enum', allowed: ENUMS.ACCENT, initial: 'blue' }),
depth: Object.freeze({ type: 'enum', allowed: ENUMS.DEPTH, initial: 'deep' }),
profile: Object.freeze({ type: 'enum', allowed: ENUMS.PROFILE, initial: 'business' }),
glow: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW, initial: 'on' }),
glowshape: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW_SHAPE, initial: 'blobs' }),
motion: Object.freeze({ type: 'enum', allowed: ENUMS.MOTION, initial: 'step' }),
rowsel: Object.freeze({ type: 'enum', allowed: ENUMS.ROW_SELECTION, initial: 'light' }),
glow_tempo: Object.freeze({ type: 'enum', allowed: ENUMS.GLOW_TEMPO, initial: 'calm' }),
glass_transparency: Object.freeze({ type: 'int', allowed: null, initial: 65, min: 0, max: 100 }),
glass_frost: Object.freeze({ type: 'int', allowed: null, initial: 3, min: 0, max: 50 }),
glass_blur: Object.freeze({ type: 'int', allowed: null, initial: 30, min: 0, max: 100 }),
glass_render: Object.freeze({ type: 'enum', allowed: ENUMS.GLASS_RENDER, initial: 'mimic' }),
blur: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
auto_blur_min: Object.freeze({ type: 'int', allowed: null, initial: 5, min: 1, max: CONFIG.AUTO_BLUR.NEVER_MIN }),
asset_show_name: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
asset_show_symbol: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
show_page_title: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
show_page_hint: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
kiosk: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
last_backup_at: Object.freeze({ type: 'string', allowed: null, initial: '' }),
last_health_check_at: Object.freeze({ type: 'string', allowed: null, initial: '' }),
start_year: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.START_YEAR.AUTO, min: CONFIG.START_YEAR.AUTO, max: CONFIG.START_YEAR.MAX }),
mwr_method: Object.freeze({ type: 'enum', allowed: ENUMS.MWR_METHOD, initial: 'dietz' }),
repeat_entry_key: Object.freeze({ type: 'enum', allowed: ENUMS.REPEAT_ENTRY_KEY, initial: 'ctrl_enter' }),
persona: Object.freeze({ type: 'enum', allowed: ENUMS.PERSONA, initial: 'bitcoin' }),
setup_done: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
market_spot: Object.freeze({ type: 'enum', allowed: ENUMS.MARKET_SPOT, initial: 'off' }),
result_signs: Object.freeze({ type: 'enum', allowed: ENUMS.RESULT_SIGNS, initial: 'negative' }),
}),
timers: Object.freeze({
spot_refresh_seconds: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS, min: CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS, max: CONFIG.TIMERS.OFF_SECONDS }),
bus_poll_seconds: Object.freeze({ type: 'int', allowed: null, initial: CONFIG.TIMERS.BUS_POLL.MIN_SECONDS, min: CONFIG.TIMERS.BUS_POLL.MIN_SECONDS, max: CONFIG.TIMERS.OFF_SECONDS }),
}),
ai: Object.freeze({
enabled: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
allow_read: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
allow_raw: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
allow_write: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
seen_call_id: Object.freeze({ type: 'int', allowed: null, initial: 0, min: 0 }),
allow_control: Object.freeze({ type: 'bool', allowed: null, initial: 0 }),
bucket_shares: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
bucket_totals: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
bucket_amounts: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
bucket_quantities: Object.freeze({ type: 'bool', allowed: null, initial: 1 }),
}),
});
export const NETWORK = Object.freeze({ MAX_SUPPLY_BTC: 21_000_000 });
export const METRICS = Object.freeze({
WINDOW_MINUTES: 60,
FAIL_WARN: 0.05,
FAIL_BAD: 0.20,
});
export const CLOCKS = Object.freeze({
LIST_MAX: 4,
OPENING_WINDOW_MINUTES: 15,
CITIES: Object.freeze([
Object.freeze({ key: 'amsterdam', name: 'Amsterdam', tz: 'Europe/Amsterdam' }),
Object.freeze({ key: 'london', name: 'Londen', tz: 'Europe/London' }),
Object.freeze({ key: 'frankfurt', name: 'Frankfurt', tz: 'Europe/Berlin' }),
Object.freeze({ key: 'newyork', name: 'New York', tz: 'America/New_York' }),
Object.freeze({ key: 'losangeles', name: 'Los Angeles', tz: 'America/Los_Angeles' }),
Object.freeze({ key: 'saopaulo', name: 'São Paulo', tz: 'America/Sao_Paulo' }),
Object.freeze({ key: 'tokyo', name: 'Tokyo', tz: 'Asia/Tokyo' }),
Object.freeze({ key: 'hongkong', name: 'Hongkong', tz: 'Asia/Hong_Kong' }),
Object.freeze({ key: 'shanghai', name: 'Shanghai', tz: 'Asia/Shanghai' }),
Object.freeze({ key: 'singapore', name: 'Singapore', tz: 'Asia/Singapore' }),
Object.freeze({ key: 'dubai', name: 'Dubai', tz: 'Asia/Dubai' }),
Object.freeze({ key: 'mumbai', name: 'Mumbai', tz: 'Asia/Kolkata' }),
Object.freeze({ key: 'sydney', name: 'Sydney', tz: 'Australia/Sydney' }),
]),
EXCHANGES: Object.freeze([
Object.freeze({ key: 'tse', name: 'Tokyo (TSE)', tz: 'Asia/Tokyo', open: '09:00', close: '15:00', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'hkex', name: 'Hongkong (HKEX)', tz: 'Asia/Hong_Kong', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'sse', name: 'Shanghai (SSE)', tz: 'Asia/Shanghai', open: '09:30', close: '15:00', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'lse', name: 'Londen (LSE)', tz: 'Europe/London', open: '08:00', close: '16:30', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'euronext', name: 'Euronext (AMS)', tz: 'Europe/Amsterdam', open: '09:00', close: '17:30', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'xetra', name: 'Xetra (FRA)', tz: 'Europe/Berlin', open: '09:00', close: '17:30', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'nyse', name: 'New York (NYSE)', tz: 'America/New_York', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }),
Object.freeze({ key: 'nasdaq', name: 'Nasdaq', tz: 'America/New_York', open: '09:30', close: '16:00', days: [1, 2, 3, 4, 5] }),
]),
});
export const DASHBOARD = Object.freeze({
GRID_COLUMNS: 12,
MIN_SPAN: 1,
MAX_SPAN: 12,
MIN_H: 1,
MAX_H: 16,
HEIGHT_STEP_REM: 3,
SETTLE_MS: 250,
LONG_PRESS_MS: 500,
LONG_PRESS_SLOP_PX: 6,
HEIGHT_MODEL: 6,
LAYOUT_MODEL: 1,
SKELETON_STEPS: 2,
REVEAL_MAX_MS: 4000,
SIZE_SPAN: Object.freeze({ s: 3, m: 4, l: 6, xl: 12 }),
WIDGETS: Object.freeze({
signal: Object.freeze({ defaultSpan: 4, instanceable: false, persona: 'bitcoin', label_key: 'dashboard.widget.signal', defaultH: 4 }),
notifications: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.notifications', defaultH: 3 }),
standing: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.standing', defaultH: 3 }),
metrics: Object.freeze({ defaultSpan: 3, instanceable: false, persona: null, label_key: 'dashboard.widget.metrics', defaultH: 1 }),
netshare: Object.freeze({ defaultSpan: 3, instanceable: false, persona: 'bitcoin', label_key: 'dashboard.widget.netshare', defaultH: 2 }),
spot: Object.freeze({ defaultSpan: 2, instanceable: true, persona: null, label_key: 'dashboard.widget.spot', defaultH: 2 }),
clocks: Object.freeze({ defaultSpan: 4, instanceable: false, persona: null, label_key: 'dashboard.widget.clocks', defaultH: 5 }),
return: Object.freeze({ defaultSpan: 4, instanceable: true, persona: null, label_key: 'dashboard.widget.return', defaultH: 4 }),
}),
DEFAULT_LAYOUT: Object.freeze([
Object.freeze({ instId: 'notifications', type: 'notifications', x: 8 }),
]),
});
export function assertConfig() {
for (const cap of CAPABILITIES) {
if (SOURCE_CAPABILITIES.filter(c => SOURCE_ENDPOINTS[c].includes(cap)).length !== 1) {
throw new Error(`Capability '${cap}' must fall under exactly one source choice. Startup aborted.`);
}
}
for (const kind of ENUMS.ONCHAIN_SOURCE_KIND) {
if (!ENUMS.WALLET_SOURCE_KIND.includes(kind)) {
throw new Error(`ONCHAIN_SOURCE_KIND '${kind}' is not in WALLET_SOURCE_KIND. Startup aborted.`);
}
}
for (const kind of ACCOUNT_INTEREST_KINDS) {
if (!ENUMS.ACCOUNT_KIND.includes(kind)) {
throw new Error(`ACCOUNT_INTEREST_KINDS '${kind}' is not an account kind. Startup aborted.`);
}
}
for (const kind of Object.keys(ACCOUNT_SOURCE_KINDS)) {
if (!ENUMS.ACCOUNT_KIND.includes(kind)) {
throw new Error(`ACCOUNT_SOURCE_KINDS '${kind}' is not an account kind. Startup aborted.`);
}
}
for (const kind of ENUMS.ACCOUNT_KIND) {
const soorten = ACCOUNT_ASSET_KINDS[kind];
if (!soorten || !soorten.length || soorten.some(s => !ENUMS.ASSET_KIND.includes(s))) {
throw new Error(`ACCOUNT_ASSET_KINDS '${kind}' is missing or names an unknown asset kind. Startup aborted.`);
}
}
if (!CONFIG.PORTFOLIO.BASE_CURRENCIES.includes(CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY)) {
throw new Error(`PORTFOLIO.DEFAULT_BASE_CURRENCY '${CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY}' is not in BASE_CURRENCIES. Startup aborted.`);
}
const essential = [
['PRICE_SOURCES.MEMPOOL_PUBLIC_BASE', CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE],
['PRICE_SOURCES.BITVAVO_BASE', CONFIG.PRICE_SOURCES.BITVAVO_BASE],
['PRICE_SOURCES.COINGECKO_BASE', CONFIG.PRICE_SOURCES.COINGECKO_BASE],
['PRICE_SOURCES.GOLDAPI_BASE', CONFIG.PRICE_SOURCES.GOLDAPI_BASE],
['PRICE_SOURCES.EULERPOOL_BASE', CONFIG.PRICE_SOURCES.EULERPOOL_BASE],
['PRICE_SOURCES.METALSDEV_BASE', CONFIG.PRICE_SOURCES.METALSDEV_BASE],
['PRICE_SOURCES.FRANKFURTER_BASE', CONFIG.PRICE_SOURCES.FRANKFURTER_BASE],
['METALS.GRAMS_PER_TROY_OUNCE', CONFIG.METALS.GRAMS_PER_TROY_OUNCE],
['METALS.SPOT_DEVIATION_WARN', CONFIG.METALS.SPOT_DEVIATION_WARN],
['CHART_LIBS.CHARTJS', CONFIG.CHART_LIBS.CHARTJS],
['CHART_LIBS.HAMMER', CONFIG.CHART_LIBS.HAMMER],
['CHART_LIBS.ZOOM', CONFIG.CHART_LIBS.ZOOM],
['SCHEMA_VERSION', CONFIG.SCHEMA_VERSION],
['MAX_ASSET_DECIMALS', CONFIG.MAX_ASSET_DECIMALS],
['PRICE_HISTORY.NEAREST_MAX_SECONDS', CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS],
['PRICE_HISTORY.CARRY_FORWARD_DAYS.crypto', CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS.crypto],
['PRICE_HISTORY.RECENT_CLOSE_DAYS', CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS],
['PRICE_HISTORY.FX_MAX_AGE_DAYS', CONFIG.PRICE_HISTORY.FX_MAX_AGE_DAYS],
['API_USAGE.WARN_SHARE', CONFIG.API_USAGE.WARN_SHARE],
['START_YEAR.MIN', CONFIG.START_YEAR.MIN],
['START_YEAR.MAX', CONFIG.START_YEAR.MAX],
['TIMERS.SPOT_REFRESH.MIN_SECONDS', CONFIG.TIMERS.SPOT_REFRESH.MIN_SECONDS],
['TIMERS.BUS_POLL.MIN_SECONDS', CONFIG.TIMERS.BUS_POLL.MIN_SECONDS],
['BUS.RETENTION_HOURS', CONFIG.BUS.RETENTION_HOURS],
['COMMANDS.CLAIM_SECONDS', CONFIG.COMMANDS.CLAIM_SECONDS],
['COMMANDS.COUNTDOWN_SECONDS', CONFIG.COMMANDS.COUNTDOWN_SECONDS],
['TIMERS.OFF_SECONDS', CONFIG.TIMERS.OFF_SECONDS],
['LND.REQUEST_TIMEOUT_MS', CONFIG.LND.REQUEST_TIMEOUT_MS],
['LND.CLIENT_TIMEOUT_MS', CONFIG.LND.CLIENT_TIMEOUT_MS],
['NWC.REQUEST_TIMEOUT_MS', CONFIG.NWC.REQUEST_TIMEOUT_MS],
['NWC.CLIENT_TIMEOUT_MS', CONFIG.NWC.CLIENT_TIMEOUT_MS],
['MARKET_SERVICE.SETTINGS_FILE_NAME', CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME],
['MARKET_SERVICE.URL', CONFIG.MARKET_SERVICE.URL],
['LICENSE.FREE_FILE_NAME', CONFIG.LICENSE.FREE_FILE_NAME],
['MARKET_SERVICE.APP_NAME', CONFIG.MARKET_SERVICE.APP_NAME],
['MARKET_SERVICE.REQUEST_TIMEOUT_MS', CONFIG.MARKET_SERVICE.REQUEST_TIMEOUT_MS],
['MARKET_SERVICE.CLIENT_TIMEOUT_MS', CONFIG.MARKET_SERVICE.CLIENT_TIMEOUT_MS],
['MARKET_SERVICE.REGISTER_TTL_MS', CONFIG.MARKET_SERVICE.REGISTER_TTL_MS],
['MARKET_SERVICE.MAX_SERIES_PAGES', CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES],
['MARKET_SERVICE.INTRADAY_MAX_DAYS', CONFIG.MARKET_SERVICE.INTRADAY_MAX_DAYS],
['MARKET_SERVICE.HISTORY_MAX_DAYS', CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS],
['MARKET_SERVICE.PROVIDER_KEY', CONFIG.MARKET_SERVICE.PROVIDER_KEY],
['MARKET_SERVICE.SPOT_TTL_MS', CONFIG.MARKET_SERVICE.SPOT_TTL_MS],
['MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT', CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT],
['AI.SERVER_NAME', CONFIG.AI.SERVER_NAME],
['AI.SERVER_VERSION', CONFIG.AI.SERVER_VERSION],
['AI.PROTOCOL_VERSION', CONFIG.AI.PROTOCOL_VERSION],
['AI.AUDIT_PAGE_SIZE', CONFIG.AI.AUDIT_PAGE_SIZE],
['AI.PROPOSAL_PAGE_SIZE', CONFIG.AI.PROPOSAL_PAGE_SIZE],
['AI.LIMITS.MAX_ROWS_PER_LIST', CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST],
['AI.LIMITS.MAX_TEXT_CHARS', CONFIG.AI.LIMITS.MAX_TEXT_CHARS],
['AI.LIMITS.MAX_LOG_CHARS', CONFIG.AI.LIMITS.MAX_LOG_CHARS],
['AI.LIMITS.METRICS_WINDOW_HOURS', CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS],
['AUTH.SESSION_TTL_MS', CONFIG.AUTH.SESSION_TTL_MS],
['AUTH.COOKIE_NAME', CONFIG.AUTH.COOKIE_NAME],
['AUTH.LOGIN_PATH', CONFIG.AUTH.LOGIN_PATH],
['AUTH.LOGIN_ATTEMPTS.FREE', CONFIG.AUTH.LOGIN_ATTEMPTS.FREE],
['AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS', CONFIG.AUTH.LOGIN_ATTEMPTS.BASE_LOCK_MS],
['AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS', CONFIG.AUTH.LOGIN_ATTEMPTS.MAX_LOCK_MS],
];
for (const [name, val] of essential) {
if (val === undefined || val === null || val === '') {
throw new Error(`Essential config missing: ${name}. Startup aborted.`);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// Generated, do not edit. sha256:ae316d9b957c83f15f4beedd3c718d48727175efe4b7a269554f9584d612e439
import { CONFIG } from './config.js';
export function originOf(url) {
try { return new URL(url).origin; } catch { return null; }
}
const NETWORK_ORIGIN = /^(https?|wss?):\/\/[^\s;,'"]+$/;
const networkOriginOf = (url) => {
const o = originOf(url);
return o && NETWORK_ORIGIN.test(o) ? o : null;
};
export function connectSrcOrigins(explorerUrls = []) {
return [...new Set([CONFIG.PRICE_SOURCES.MEMPOOL_PUBLIC_BASE, ...explorerUrls]
.map(networkOriginOf).filter(Boolean))];
}
export function originAllowedByCsp(url, eigenOrigin = null, explorerUrls = []) {
const origin = originOf(url);
if (!origin) return false;
if (eigenOrigin && origin === originOf(eigenOrigin)) return true;
return connectSrcOrigins(explorerUrls).includes(origin);
}
+64
View File
@@ -0,0 +1,64 @@
// Generated, do not edit. sha256:26b76ae49e193597112630c3ad94ab2a26c04d90427102a41f017d09ef2fec9a
import { sharedText } from './i18n/index.js';
const TOKEN = /\{([A-Z][A-Z0-9_]*)\}|\{icon:([a-z][a-z0-9-]*)\}/g;
export const GUIDE_ICONS = Object.freeze(['adjust']);
export const GUIDE_FIGURES = Object.freeze(['stand-period', 'account-example', 'result-sum']);
export function splitPlaceTokens(text) {
const parts = [];
let last = 0;
for (const m of String(text).matchAll(TOKEN)) {
if (m.index > last) parts.push(text.slice(last, m.index));
parts.push(m[1] ? { place: m[1] } : { icon: m[2] });
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
const section = (s) => Object.freeze({ place: null, related: [], ...s });
export const GUIDE_GROUPS = Object.freeze([
Object.freeze({ id: 'concepts',
sections: ['overview', 'bookings', 'anchors', 'free-value', 'returns', 'account-returns',
'two-balances'] }),
Object.freeze({ id: 'pages',
sections: ['actions', 'wealth', 'taxes', 'tracking', 'interest', 'price-history', 'charts',
'conversion'] }),
Object.freeze({ id: 'setup',
sections: ['dashboard', 'accounts', 'assets', 'price-sources', 'connections', 'lnd', 'ai', 'health', 'license',
'portfolios'] }),
]);
export const GUIDE_SECTIONS = Object.freeze([
section({ id: 'overview', related: ['bookings', 'two-balances', 'price-sources'] }),
section({ id: 'bookings', place: 'EVENT_ACCOUNTS', related: ['anchors', 'free-value', 'two-balances'] }),
section({ id: 'anchors', place: 'VALUATIONS', related: ['bookings', 'actions'] }),
section({ id: 'free-value', related: ['bookings', 'wealth'] }),
section({ id: 'returns', place: 'WEALTH', related: ['wealth', 'account-returns', 'free-value', 'taxes'] }),
section({ id: 'account-returns', place: 'WEALTH', related: ['returns', 'wealth', 'accounts'] }),
section({ id: 'two-balances', place: 'TRACKING', related: ['overview', 'tracking'] }),
section({ id: 'wealth', place: 'WEALTH', related: ['returns', 'account-returns', 'free-value', 'taxes'] }),
section({ id: 'taxes', place: 'TAXES', related: ['anchors', 'wealth'] }),
section({ id: 'actions', place: 'ACTIONS', related: ['anchors', 'two-balances'] }),
section({ id: 'price-sources', place: 'SOURCE_PROPOSALS', related: ['overview', 'price-history'] }),
section({ id: 'tracking', place: 'TRACKING', related: ['two-balances', 'connections', 'actions'] }),
section({ id: 'connections', place: 'CONNECTIONS', related: ['tracking', 'lnd', 'overview'] }),
section({ id: 'lnd', place: 'CONNECTIONS', related: ['connections'] }),
section({ id: 'ai', place: 'AI_ACTIVITY', related: ['actions'] }),
section({ id: 'interest', place: 'INTEREST', related: ['bookings', 'free-value', 'actions'] }),
section({ id: 'health', place: 'HEALTH', related: ['anchors', 'price-sources'] }),
section({ id: 'dashboard', place: 'HOME', related: ['overview'] }),
section({ id: 'price-history', place: 'PRICE_HISTORY', related: ['price-sources', 'health'] }),
section({ id: 'charts', place: 'CHARTS', related: ['price-history'] }),
section({ id: 'conversion', place: 'CONVERSION', related: ['charts'] }),
section({ id: 'assets', place: 'ASSETS', related: ['overview', 'price-sources'] }),
section({ id: 'accounts', place: 'ACCOUNTS', related: ['bookings', 'tracking', 'wealth', 'account-returns'] }),
section({ id: 'license', place: 'LICENSE', related: ['overview'] }),
section({ id: 'portfolios', place: 'PORTFOLIOS', related: ['overview'] }),
]);
export const guideText = (code) => sharedText(code).guide;
+405
View File
@@ -0,0 +1,405 @@
// Generated, do not edit. sha256:e2490264df449b0893dfa0c9153c9a28823fc105b7143f0c7281166235765818
export const en = {
places: {
HOME: 'The dashboard. You build the layout yourself from widgets (add, drag, adjust width, restore the default layout) and see balances, prices and signals at a glance.',
ACTIONS: 'To do. Everything waiting for you is collected here: reconcile proposals from a wallet scan, interest that can be credited, and differences between a balance and your records. You handle them here or set them aside.',
CHARTS: 'Signals & Charts for Bitcoin. You read the three traffic lights (Power Law, 200-week average, Mayer Multiple) and view the four charts below them; per chart you can zoom, switch the scale and go full screen.',
CHART_PRICE: 'The Bitcoin price chart. You switch between linear and logarithmic scale and zoom in on a period.',
CHART_POWERLAW: 'The Power Law corridor. You adjust the lower and upper band to see where the price stands relative to the long-term trend.',
CHART_WMA: 'The 200-week average with its signal threshold. Meant to assess the historical bottom zone; you switch the scale and zoom.',
CHART_MAYER: 'The Mayer Multiple: the price divided by the 200-day average, with the zones for overvaluation and undervaluation.',
TAXES: 'Taxes. Per account and asset you see the value on each reference date and the gain/loss per year, and you can point out which valuations are still missing. Nothing is recorded here: the figures follow from your records.',
WEALTH: 'Wealth. Per asset your position, the current value, the average purchase price and the gain/loss. An overview to read; you record entries under Events.',
TRACKING: 'On-chain tracking. Per tracking source the balance and the transactions, grouped by date, with a filter per account. You start a scan here and label transactions.',
PRICE_HISTORY: 'Price history. The full price history per source and asset in one management table: search, filter, add or delete a point, and compare sources side by side.',
GUIDE: 'Help. You read how the app is put together and where its figures come from; nothing changes here.',
CONVERSION: 'Bitcoin conversions. You convert live between euro, dollar and BTC, mBTC, bit, sat and msat; each field updates the others. Nothing is recorded.',
EVENT_ACCOUNTS: 'Accounts under Events. Per account you see the current balance per asset and the date of the last balance, and you open an account to view and edit its balances, buys and sells, transfers and interest together.',
VALUATIONS: 'Balances. You record what an account actually held on a date, so that a difference with your records becomes visible. You can add, edit and delete them here.',
TRADES: 'Buy & Sell. You record a purchase or sale with quantity, price and fees, and edit or delete an existing entry.',
TRANSFERS: 'Transfers. You record a transfer between two of your own accounts or to an external address, with any fees in the asset itself.',
INTEREST: 'Interest & Rewards. You maintain interest schedules per account (rate, frequency, term) and record the credits that follow from them.',
RESERVATIONS: 'Reservations. Per account you set aside an amount that may later be debited or credited; it counts nowhere until you record it or settle it without an entry.',
ACCOUNTS: 'Accounts. You create an account, close or reopen it, turn on automatic scanning, and link tracking sources to it (an xpub or a single address). Merging is also possible here.',
ASSETS: 'Assets. You add or edit an asset (symbol, unit, subunit, precision, display), and manage the metal products under it.',
PORTFOLIOS: 'Portfolios. Each portfolio is a separate set of records, with its own database and its own secrets; exactly one is always open. You create one (empty or as a copy of an existing one), switch to another (the app restarts for this), rename or delete one, and set who may access this portfolio. Deleting moves it to the archive; from there it can be removed permanently. This is also where you handle your records as a file: make a backup, export or restore the database, and start empty.',
SETTINGS: 'Settings. Theme and accent color, currency, language, the privacy blur, which name an asset gets in tables, and the intervals of the background tasks.',
LICENSE: 'License. Which version this installation runs, who the license is for and for which major versions, and where you put a license file. Every lock elsewhere in the app points here.',
IMPORT: 'Import. You read in events from a CSV file, map the columns to the fields and see before saving what would be added.',
DATA: 'Data. A read-only look at the raw contents of the tables. Meant for checking, not for editing; the backup is under Portfolios.',
HEALTH: 'Health. Ten checks on your records themselves, with for each finding what is going on: duplicate entries, balances that are not reconciled, lag and gaps in the price history, transfers without a counterpart. You read here; you handle things on To do.',
AI_ACTIVITY: 'AI activity. You turn the AI connection and the navigation permission on or off, review per call what happened and which fields left the app, and preview what an action would share.',
INTEGRATIONS: 'Integrations. Managing everything outside the app: price sources, their API keys, the connections to an explorer or node, and proposals for new sources.',
PROVIDER_KEYS: 'Integrations, Providers tab. Per price source the endpoints, the mapping, the API key, a test and the measurements; add, enable or delete a source.',
SOURCE_PROPOSALS: 'Integrations, Source choice tab. Per asset and use the ranking of price sources, with fallback; confirm or adjust an open proposal.',
CONNECTIONS: 'Integrations, Connections tab. Add, test and activate a Bitcoin explorer, LND node or NWC wallet.',
},
guide: {
groups: {
concepts: 'Concepts',
pages: 'Pages',
setup: 'Setup',
},
sections: {
overview: {
title: 'What the app consists of',
body: [
'Two groups. The first describes what you own, the second where the app gets its data.',
{ heading: 'Your records' },
{ list: [
['Asset', 'What you own: bitcoin, euro, gold. The asset determines the unit used in calculations.'],
['Account', 'Where it is held: a wallet, an exchange, a vault.'],
['Entry', 'One event on an account: a purchase, a sale, a transfer or interest.'],
['Balance', 'A recorded position on a date. From that position the app continues with the entries after it.'],
['Label', 'A short name for an on-chain transaction, reusable.'],
['Metal product', 'A coin or bar under a metal asset, with its fine weight in grams.'],
] },
{ heading: 'The three sources' },
'All three are called a source. They answer different questions.',
{ list: [
['Tracking source', 'Which wallet. An xpub, an address or a Lightning connection the app uses to read the balance of one account. An exchange or vault has none; that balance comes from the entries.'],
['Price source', 'Which price. The service that supplies the price of an asset, with a ranking and fallback per asset and use.'],
['Connection', 'Which address. Where the app requests its data: your own node or a public service. One connection serves multiple sources.'],
] },
],
},
bookings: {
title: 'Four types of entries',
body: [
'Every entry belongs to an account and an asset. There are four types, each with its own tab.',
{ list: [
['Buy and sell', 'Money in exchange for an asset or the other way around, with quantity, price and fees. On {TRADES}.'],
['Transfer', 'The same asset from one account to another, or to an address outside the app. The fees are deducted in the asset itself. On {TRANSFERS}.'],
['Interest and rewards', 'What is added because something is held: interest, staking, cashback. A single credit is possible, or a schedule from which the app proposes the credits. On {INTEREST}.'],
['Balance', 'Not a movement but a recorded position on a date. On {VALUATIONS}.'],
] },
'On {EVENT_ACCOUNTS} each account shows its current balance. Clicking an account opens all four together, to view and edit.',
],
},
anchors: {
title: 'A balance is an anchor',
body: [
'The position of an account is the latest balance plus the entries after it. Entries from before that balance no longer count toward the current position.',
'This way you can keep an account up to date without recording every movement, for example a savings account or a vault tracked by balance.',
{ heading: 'A difference' },
'If there are also entries before the balance, the app checks whether they arrive at the same position. If not, the difference appears on {ACTIONS}, with two choices:',
{ list: [
['Record', 'The difference becomes one purchase or sale at the price of that moment.'],
['Accept', 'The difference counts as money that entered or left your records, not as return. Can be undone.'],
] },
'A balance without entries before it is an opening balance: there is nothing to compare it with.',
{ term: 'Record balance', text: 'in the window of an account records a balance at this moment, not at the start of the day. By default the button is only shown for money; per asset you can turn it on at {ASSETS}.' },
],
},
'free-value': {
title: 'Received for free: income or gift',
body: [
'Value that came in without payment is recorded by its reason, not by the way it arrived.',
{ list: [
['Income', 'Received because you were a customer or held something: interest, staking, cashback, a welcome bonus. Record it on {INTEREST}; it counts as gain/loss.'],
['Gift', 'Someone else gave it: an inheritance, a present. Record it as a purchase or transfer, with the Origin set to Gift. The app deducts the value at the daily price from the gain/loss.'],
] },
{ note: 'A wrong choice cannot be seen in the figures afterward.' },
'A transfer between two of your own accounts is neither: the total does not change.',
],
},
returns: {
title: 'What is return, what is gain/loss?',
body: [
'The figures on {WEALTH} look alike but measure different things. Unrealized and Invested are a current position; Return, Gain/loss and Realized cover the selected period.',
{ figure: 'stand-period', caption: 'Unrealized and Invested count since the purchase; Return, Gain/loss and Realized only over the selected period.' },
{ heading: 'What is return?' },
'A percentage that measures only the price. It is time-weighted: how much was bought and when does not count. It follows the year selection. The money-weighted return, which does include deposits, is on {TAXES}.',
{ heading: 'What is gain/loss?' },
'What the holdings yielded, as an amount over the selected period: the change in value minus the net amount invested (purchases in, sales out). Price gains, the outcome of sales and interest count. A gift counts as a contribution, not as gain/loss.',
{ heading: 'What is unrealized?' },
'The gain or loss on what you hold now, if it were sold now: the current value minus what those units cost, at the average cost basis including fees and premium. A current position: always since the purchase, regardless of the year selection. What has already been sold does not count.',
{ heading: 'What is realized?' },
'The gain or loss on what was sold in the selected period: the proceeds minus fees and the cost basis of the units sold.',
{ heading: 'What is invested?' },
'Per asset all purchases plus fees, including what has already been sold. After a sale, value minus invested is therefore no longer the same as unrealized. The annual overview shows Invested (net): per year the purchases plus fees, minus what sales brought in.',
{ heading: 'What is contributed (net)?' },
'The same for an account: money in plus every other flow, including a transfer from another of your own accounts at the daily price of that day. Interest does not count as a contribution. The value minus this amount is the gain/loss of the account.',
{ heading: 'How they relate' },
{ figure: 'result-sum', caption: 'The gain/loss over all years, split into its three parts. The proportions are an example.' },
'Over all years together, the gain/loss is roughly unrealized plus realized plus interest. A gift is the exception: it has a cost basis of zero, so Unrealized includes its full value, while the gain/loss deducts it as a contribution at the daily price.',
{ note: 'Unrealized and Gain/loss can have a different sign and both still be correct: the first measures against the purchase, the second over the selected period. This is common per account; see By account: contributed and gain/loss.' },
],
},
'account-returns': {
title: 'By account: contributed and gain/loss',
body: [
'On an account card, Unrealized can be negative while Gain/loss is positive. That is not an error: the two measure from a different moment.',
{ list: [
['Unrealized', 'For your own account, in proportion to the units, at the average cost basis of the asset: within own wealth a coin costs the same everywhere. A third-party account has its own cost basis, see below.'],
['Contributed (net)', 'Money in plus every other flow, including a transfer from another of your own accounts at the daily price of that day. Interest does not count as a contribution.'],
['Gain/loss', 'The change in value minus what was contributed. Over all years, that is the space between the two lines on the card: the value and what was contributed.'],
['Return', 'A transfer between your own accounts counts as a withdrawal from one account and a deposit to the other, at the daily price. Only the price and the fees count.'],
] },
{ heading: 'An example' },
{ figure: 'account-example', caption: 'The current value is 4.77 below the cost basis of the asset, but 31.51 above what came in with the transfer. The green area is the gain/loss.' },
'Coins bought on an exchange for 191.20 in total, transferred to a cold wallet when they were worth 154.92, and now worth 186.43. Unrealized is -4.77: below what was paid for them. Contributed (net) is 154.92 and the gain/loss 31.51: the rise since the coins have been on the cold wallet.',
'Invested, the average purchase price and realized cannot be split per account. Those are shown with the asset.',
{ heading: 'Third-party wealth' },
'An account of someone else, for example a child\'s savings account, does not share the cost basis with own wealth. A transfer to it is a withdrawal at the daily price, and that value is the cost basis of that account. If the cold wallet above belonged to someone else, Unrealized there would be 31.51: measured from 154.92.',
'What the price did before the transfer stays in the gain/loss of own wealth. Even with Show all, the two stay separate: in the window of an asset, third-party wealth is on its own tab, with its own key figures.',
],
},
'two-balances': {
title: 'Two sources for one balance',
body: [
'The app has two answers to the question of how much there is. For holdings with an on-chain address they are equal.',
{ list: [
['Scanned balance', 'Shown on {TRACKING}. The app reads the addresses of the wallet and adds up what the chain reports there.'],
['Ledger', 'Shown on {WEALTH} and on {TAXES}. The sum of the entries: purchases, sales, transfers and interest.'],
] },
{ heading: 'Why they can differ' },
{ list: [
['No address', 'An account at an exchange or a custodian has no xpub, so there is nothing to read. Those holdings are only in the ledger.'],
['Fee not recorded', 'The chain deducts the transaction fee, the ledger does not.'],
['Entry without address', 'Counts on {WEALTH}, not on {TRACKING}.'],
] },
{ heading: 'What you can do about it' },
{ list: [
'On-chain transactions without an entry appear as a proposal on {ACTIONS}. After approval the two match.',
'If the cause cannot be found, record a balance. The app takes that position as the truth and adds the entries after it.',
] },
],
},
wealth: {
title: 'Position, gain/loss and return',
body: [
'Per asset or per account the current value, the gain/loss and the return. Nothing is recorded here; that happens on the entry pages.',
{ heading: 'What is where' },
{ list: [
['Asset card', 'Quantity, Value, Share of total, Unrealized, Gain/loss and Return, and Unexplained if a balance differs from the entries.'],
['Account card', 'The same without Quantity. The account cards add up to the total.'],
['Asset window', 'On the Own wealth tab, Avg. purchase price, Avg. premium and Invested, per account. With third-party wealth in view, the same key figures for third parties are on the Third-party wealth tab. On the Return tab, the return, the gain/loss and realized per year.'],
['Page column', 'Invested, Unrealized, Gain/loss and Return of the whole.'],
] },
{ term: 'Plus and minus', text: 'a green or red number has no plus sign; the minus sign is always shown. Without color, as on the dashboard\'s return widget, both plus and minus are shown.' },
{ list: [
['Avg. purchase price', 'What was paid per unit, without fees.'],
['Avg. premium', 'The fees per purchase, including the premium for metal.'],
] },
'What Unrealized, Gain/loss, Return and Invested measure exactly is explained under the concepts in See also.',
{ heading: 'Own wealth and third-party wealth' },
'An account of someone else or an account that does not count is marked as such in the Wealth column on {ACCOUNTS}. Wealth and the dashboard then show only own wealth; the Show all switch at the total shows everything.',
],
},
taxes: {
title: 'Taxes: key figures, but no tax return',
body: [
'The page provides the figures for a tax return. The app does not calculate the tax itself; that is up to the country of the tax return.',
{ list: [
['Value per reference date', 'Per account and asset on January 1 of each year, with a copy button per amount.'],
['Category', 'Bank balances and other assets separately, for a tax return that asks for this. Appears only if a category has been chosen on an account or asset.'],
['Gain/loss', 'Per year the start, end, deposits, withdrawals and return. This return is money-weighted: it takes into account when money went in or out.'],
] },
'What counts is set in the Taxes column on {ACCOUNTS} and {ASSETS}. A choice on the account takes precedence over the one on the asset.',
'If the oldest entry goes back further than needed, set the start year on {PORTFOLIOS}. That removes columns, not entries: the balance in the first column stays complete.',
],
},
actions: {
title: 'What awaits a decision',
body: [
'Everything that awaits a decision is in one place. The column on the left shows per block how many items are open.',
{ list: [
['AI proposals', 'What the AI wants to record. It only happens after approval.'],
['Transactions', 'A transaction on the chain without an entry, as a proposal: purchase, transfer or ignore.'],
['Interest to record', 'Credits that follow from an interest schedule.'],
['Differences', 'A balance that does not match the entries before it.'],
] },
],
},
'price-sources': {
title: 'Price sources and the source choice',
body: [
'A price source is the service that provides the price of an asset. Which source that is, is set per asset and per use on {SOURCE_PROPOSALS}: a ranking, and if the first one drops out, the next one takes over.',
'Use means what the price is for. The price on a reference date for {TAXES} can come from a different source than the current price.',
{ list: [
['New asset', 'Gets its default source right away, if there is one. You make a different choice on {SOURCE_PROPOSALS}.'],
['API keys', 'A source that requires an API key gets it on {PROVIDER_KEYS}. The key stays on the server; the browser does not see it.'],
['Coverage', 'A source only provides the assets it knows. For an asset outside its coverage, the app skips it.'],
['Manual', 'A price you entered yourself, for something without a market or a day a source is missing.'],
] },
'On {PRICE_HISTORY} you see per asset which prices exist and which source they come from.',
],
},
tracking: {
title: 'Tracking wallets on-chain',
body: [
'A tracking source is an xpub or a single address under an account. With it, the app reads what happens on the chain. You link it on {ACCOUNTS}.',
{ list: [
['Update', 'On {TRACKING} the app reads the addresses again. Each transaction is listed per wallet, as received or sent, with the fee.'],
['Proposal', 'A transaction without an entry becomes a proposal on {ACTIONS}. After approval it shows that it has also been registered as an event, and it counts toward Linked.'],
['Label', 'A short name for a transaction, reusable.'],
] },
'An account at an exchange or in a vault has no tracking source. Its balance comes from the entries.',
],
},
connections: {
title: 'Connections: where the app requests data',
body: [
'A connection is an address where the app requests data. One is active per type; Test checks whether the address responds.',
{ list: [
['Bitcoin explorer', 'Reads addresses and transactions for {TRACKING} and provides the price from mempool. A public service by default. With your own node, the addresses do not go to a third party, and an xpub scan does not run into a limit.'],
['Lightning node', 'LND. Reads the Lightning side of your own node. The app talks to the node through its own server; macaroon and certificate stay on that server.'],
] },
'Your own explorer and Lightning are part of the full version. The default explorer always works.',
],
},
lnd: {
title: 'Connecting an LND node',
body: [
'Use a read-only macaroon. You create it on the node with:',
{ code: 'lncli bakemacaroon info:read offchain:read invoices:read onchain:read peers:read' },
'Paste the output under Macaroon. Hex is fine, base64 too.',
{ note: 'Not the macaroon from an lndconnect URL: that is usually the admin macaroon, which can make payments from the node.' },
{ list: [
['tls.cert', 'With the certificate, the app verifies that it is talking to your own node. Without it, the connection is encrypted but not verified; acceptable over a tunnel or your own network.'],
['Address', 'A bare IP address with port is fine; the app turns it into an https address.'],
['Timeout', 'The node cannot be reached. First check whether the tunnel is on.'],
] },
],
},
ai: {
title: 'The AI connection',
body: [
'An AI program on this computer can read the app and make proposals. The program starts the connection itself; no web address or password is needed. The connection is part of the full version.',
{ heading: 'What the AI may do' },
{ list: [
['Active', 'The connection itself. When off, every call is rejected and a running AI program stops.'],
['Read', 'View your records.'],
['Write', 'Add or change something, always as a proposal on {ACTIONS} that must be approved first.'],
['Navigation', 'Move the screen to another page, after a countdown with a stop button.'],
['Share raw', 'When off, the AI sees references and ranges instead of names and amounts.'],
] },
{ note: 'Only turn on Share raw in a portfolio without real data.' },
'Below that, four sliders set how precisely the AI sees the figures: percentages, totals, amounts and quantities, as a range or exact.',
'Every call is listed on {AI_ACTIVITY}, with the data that left the app. The settings apply per portfolio and immediately, without a restart.',
],
},
interest: {
title: 'Interest schedules',
body: [
'An interest schedule records what an account earns: rate, frequency and term. From that, the app calculates the credits and prepares them on {ACTIONS}.',
{ list: [
['Principal', 'When empty, it follows the account balance; that suits a savings account. For a term deposit, enter the starting amount here.'],
['Compound interest', 'Only with a fixed principal: each period then also calculates over the earlier credits. A principal that follows the balance already does this by itself.'],
['Rate periods', 'If the rate changes, record a period with the start date.'],
['Order', 'Credits are recorded in order: each credit increases the balance the next one is calculated on.'],
] },
'A single credit without a schedule is also possible, on {INTEREST}.',
],
},
health: {
title: 'Health: checks on your records',
body: [
'Run check goes through ten checks on the records themselves. This only happens on request, because it can take a while.',
{ list: [
'Duplicate entries, and transfers without a counterpart.',
'Balances that are not reconciled.',
'Gaps, stale data and suspicious prices in the price history.',
] },
'A finding is marked as probably unintended or for information. If it is intended, This is correct removes it; it comes back as soon as something changes.',
'Fixing happens elsewhere: in the entries, on {ACTIONS} or on {PRICE_HISTORY}.',
],
},
dashboard: {
title: 'Setting up the dashboard',
body: [
'The dashboard consists of widgets you put together yourself, in one or more layouts.',
{ list: [
['Edit', 'Long-press a widget, or use the {icon:adjust} button next to Dashboard in the menu; it appears when the mouse is over it. Escape, a click outside the grid or the same button turns edit mode off again.'],
['Add', 'In edit mode, the empty slot opens the menu with widgets.'],
['Arrange', 'Drag a widget, or adjust its width in the menu in its header.'],
['Layouts', 'Several saved dashboards to switch between. In edit mode they are shown as a list, with buttons for a new layout, renaming and deleting.'],
] },
'Deleting Default starts it over, with the layout the app started with.',
],
},
'price-history': {
title: 'Price history: where each price comes from',
body: [
'Per asset, a card with the origin of each daily price over the past year. A click opens the window with, among other things, the origin, the gaps and the prices themselves.',
{ heading: 'What the app knows about a daily price, from strong to weak' },
{ list: [
['Closing price', 'The price from the source at the end of that day.'],
['Manual', 'A price you entered yourself.'],
['Today', 'The current price, which is still moving.'],
['Derived', 'From a spot price of the source, not a closing price.'],
['Carried forward', 'An earlier price, with how many days old it is.'],
['No price', 'Counts as zero. An amount affected by this gets a yellow i.'],
] },
{ heading: 'Gaps' },
'A gap is a series of days without a price. Update requests the missing prices from the source. A day that no source has can be ignored or filled in manually.',
{ note: 'With a source that has a monthly limit, updating costs one request per day.' },
],
},
charts: {
title: 'Signals and charts',
body: [
'Three signals for bitcoin, each as a light in the column. Green, yellow and red are a judgment here; a click on a light opens that chart.',
{ list: [
['Power Law', 'The price against the long-term trend, in log-log. The line is a fit on the history, not a prediction; the bands around it are a fixed factor.'],
['Weekly averages', 'The 200-week average is the slow bottom zone of the cycle. The 50-week average is the dividing line between a rising and a falling market.'],
['Mayer Multiple', 'The price divided by the 200-day average. Below 1.0 is historically a rare low zone, above 2.4 overheated.'],
] },
'Each chart can be linear or logarithmic, zoomed in and shown full screen.',
],
},
conversion: {
title: 'Conversions',
body: [
'A calculator between money and the units of bitcoin: BTC, mBTC, bit, sat and msat. Each field updates the others live. Nothing is recorded.',
],
},
assets: {
title: 'Assets and metals',
body: [
'An asset is something you own whose price the app tracks, with symbol, name, type, main unit, subunit and decimals.',
{ list: [
['Type', 'Crypto, metal or currency. The type determines the default category on {TAXES} and whether Record balance is on; this can be set differently per asset.'],
['Metal', 'Calculates in grams. The app converts a price per troy ounce to grams.'],
['Coins and bars', 'Products under a metal asset, with their fine weight. A purchase of a product records the number of pieces; the app calculates the weight.'],
['Delete', 'Only possible once no entry refers to the asset anymore.'],
] },
],
},
accounts: {
title: 'Accounts',
body: [
'An account is where something is held: a wallet, an exchange, a card, a bank, a vault or a Lightning wallet.',
{ list: [
['Tracking source', 'An xpub or address under the account lets the app read the balance on the chain.'],
['Wealth', 'Whether it is own wealth, managed for someone else, or does not count.'],
['Taxes', 'Whether it counts in the tax return. Separate from Wealth.'],
['Close', 'A closed account disappears from the dropdowns, but still counts in balances, positions and taxes.'],
['Merge', 'Two accounts that are the same become one.'],
] },
],
},
license: {
title: 'Free and full version',
body: [
'The free version has no time limit, with small records: one asset, one fiat account, one balance and one portfolio.',
'Integrations, the source choice, your own API keys and the tax page are part of the full version. Reading and exporting always keep working; nothing is ever taken away.',
'The license file goes on {LICENSE} and takes effect immediately. Every lock elsewhere in the app points here.',
],
},
portfolios: {
title: 'Portfolios',
body: [
'Each portfolio is a separate set of records, with its own database and its own secrets. One is always open.',
{ list: [
['Base currency', 'Chosen at creation and fixed after that. All amounts are in that currency.'],
['Switch', 'The app restarts to do so.'],
['Delete', 'Moves the portfolio to the archive. Only from there can it be removed permanently.'],
] },
{ note: 'A backup contains the database and secrets together: they only match together.' },
],
},
},
},
};
+8
View File
@@ -0,0 +1,8 @@
// Generated, do not edit. sha256:a416595fbf1033b4f4a08083413027258e72584eb9e1cac5688d5eae76866b95
import { CONFIG } from '../config.js';
import { nl } from './nl.js';
import { en } from './en.js';
export const SHARED_TEXT = Object.freeze({ nl, en });
export const sharedText = (code) => SHARED_TEXT[code] ?? SHARED_TEXT[CONFIG.INSTALLATION.DEFAULT_LOCALE];
+405
View File
@@ -0,0 +1,405 @@
// Generated, do not edit. sha256:88c3787099bdacec2cc7f0323d565d172c1d53d814c3d3c14973885706e0e046
export const nl = {
places: {
HOME: 'Het dashboard. U stelt de indeling zelf samen uit widgets (toevoegen, verslepen, breedte aanpassen, standaardindeling herstellen) en ziet in één oogopslag saldi, koersen en signalen.',
ACTIONS: 'Af te handelen. Alles wat op u wacht staat hier bij elkaar: reconcile-voorstellen van een wallet-scan, rente die bijgeschreven kan worden, en verschillen tussen een balans en de administratie. U handelt ze hier af of legt ze weg.',
CHARTS: 'Signalen & Grafieken voor Bitcoin. U leest de drie stoplichten (Power Law, 200-weeks gemiddelde, Mayer Multiple) en bekijkt daaronder de vier grafieken; per grafiek kunt u zoomen, de schaal wisselen en op volledig scherm zetten.',
CHART_PRICE: 'De prijsgrafiek van Bitcoin. U wisselt tussen lineaire en logaritmische schaal en zoomt in op een periode.',
CHART_POWERLAW: 'De Power Law-corridor. U schuift de onder- en bovenband bij om te zien waar de koers ten opzichte van de langetermijntrend staat.',
CHART_WMA: 'Het 200-weeks gemiddelde met zijn signaaldrempel. Bedoeld om de historische bodemzone te beoordelen; u wisselt de schaal en zoomt.',
CHART_MAYER: 'De Mayer Multiple: de koers gedeeld door het 200-daags gemiddelde, met de zones voor over- en onderwaardering.',
TAXES: 'Belastingen. U ziet per account en asset de waarde op elke peildatum en het resultaat per jaar, en u kunt aanwijzen welke waarderingen nog ontbreken. Hier wordt niets geboekt: de cijfers volgen uit de administratie.',
WEALTH: 'Vermogen. Per asset uw positie, de actuele waarde, de gemiddelde aankoopkoers en het resultaat. Een overzicht om te lezen; boeken doet u bij Gebeurtenissen.',
TRACKING: 'On-chain analyse. Per wallet-bron het saldo en de bewegingen, gegroepeerd per datum, met een filter per account. U start hier een scan en labelt bewegingen.',
PRICE_HISTORY: 'Prijsgeschiedenis. De volledige koershistorie per bron en asset in één beheertabel: zoeken, filteren, een punt toevoegen of verwijderen, en bronnen naast elkaar vergelijken.',
GUIDE: 'Hulp. U leest hoe de app in elkaar zit en waar zijn cijfers vandaan komen; hier wijzigt niets.',
CONVERSION: 'Bitcoin-conversies. U rekent live tussen euro, dollar en BTC, mBTC, bit, sat en msat; elk veld rekent de andere door. Er wordt niets vastgelegd.',
EVENT_ACCOUNTS: 'Accounts onder Gebeurtenissen. U ziet per account het saldo van nu per asset en de datum van de laatste balans, en opent een account om zijn balansen, aan- en verkopen, verplaatsingen en rente samen te bekijken en te bewerken.',
VALUATIONS: 'Balansen. U legt vast wat een account op een datum werkelijk aanhield, zodat een verschil met de administratie zichtbaar wordt. Toevoegen, wijzigen en verwijderen kan hier.',
TRADES: 'Aankoop & Verkoop. U boekt een aankoop of verkoop met hoeveelheid, koers en kosten, en wijzigt of verwijdert een bestaande boeking.',
TRANSFERS: 'Verplaatsingen. U boekt een overboeking tussen twee eigen accounts of naar een extern adres, met eventuele kosten in de asset zelf.',
INTEREST: 'Rente & Beloning. U onderhoudt renteafspraken per account (percentage, frequentie, looptijd) en boekt de bijschrijvingen die daaruit volgen.',
RESERVATIONS: 'Reserveringen. U legt per account een bedrag vast dat later mogelijk wordt afgeschreven of bijgeboekt; het telt nergens mee tot u het boekt of zonder boeking afhandelt.',
ACCOUNTS: 'Accounts. U maakt een account aan, sluit het af of heropent het, zet automatisch scannen aan, en koppelt er wallet-bronnen aan (een xpub of los adres). Ook samenvoegen kan hier.',
ASSETS: 'Assets. U voegt een asset toe of wijzigt hem (symbool, eenheid, ondereenheid, precisie, weergave), en beheert de metaalproducten die eronder hangen.',
PORTFOLIOS: 'Portefeuilles. Elke portefeuille is een eigen administratie, met een eigen database en eigen geheimen; er is er altijd precies één open. U maakt er een aan (leeg of als kopie van een bestaande), schakelt over naar een andere (de app start daarvoor opnieuw), hernoemt of verwijdert er een, en stelt in wie er bij deze portefeuille mag. Verwijderen verplaatst naar het archief; daar kan het definitief weg. Hier staan ook de handelingen op de administratie als bestand: een back-up maken, de database exporteren of terugzetten, en leeg beginnen.',
SETTINGS: 'Instellingen. Thema en accentkleur, valuta, taal, de privacy-blur, welke naam een asset in tabellen krijgt, en de intervallen van de achtergrondtaken.',
LICENSE: 'Licentie. Welke versie deze installatie draait, voor wie de licentie is en voor welke hoofdversies, en waar u een licentiebestand neerzet. Elk slot elders in de app verwijst hierheen.',
IMPORT: 'Importeren. U leest gebeurtenissen in uit een CSV-bestand, koppelt de kolommen aan de velden en ziet vóór het opslaan wat er zou worden toegevoegd.',
DATA: 'Data. Een read-only kijkje in de rauwe inhoud van de tabellen. Bedoeld om te controleren, niet om te wijzigen; de back-up staat bij Portefeuilles.',
HEALTH: 'Gezondheid. Tien controles op de administratie zelf, met per bevinding wat er aan de hand is: dubbele boekingen, balansen die niet verzoend zijn, achterstand en gaten in de koershistorie, verplaatsingen zonder tegenkant. U leest hier, u handelt af op Af te handelen.',
AI_ACTIVITY: 'AI-activiteit. U zet de AI-koppeling en het navigatierecht aan of uit, leest per aanroep na wat er gebeurde en welke velden de app verlieten, en bekijkt vooraf wat een handeling zou delen.',
INTEGRATIONS: 'Integraties. Het beheer van alles wat buiten de app zit: koersbronnen, hun sleutels, de verbindingen met een explorer of node, en voorstellen voor nieuwe bronnen.',
PROVIDER_KEYS: 'Integraties, tabblad Providers. Per koersbron de endpoints, de mapping, de API-sleutel, een test en de metingen; een bron toevoegen, aanzetten of verwijderen.',
SOURCE_PROPOSALS: 'Integraties, tabblad Bronkeuze. Per asset en gebruik de rangorde van koersbronnen, met terugval; een openstaand voorstel bevestigen of aanpassen.',
CONNECTIONS: 'Integraties, tabblad Verbindingen. Een Bitcoin-explorer, LND-node of NWC-wallet toevoegen, testen en actief maken.',
},
guide: {
groups: {
concepts: 'Begrippen',
pages: 'Pagina\'s',
setup: 'Inrichten',
},
sections: {
overview: {
title: 'Waar de app uit bestaat',
body: [
'Twee groepen. De eerste beschrijft wat je bezit, de tweede waar de app zijn gegevens haalt.',
{ heading: 'De administratie' },
{ list: [
['Asset', 'Wat je bezit: bitcoin, euro, goud. De asset bepaalt de eenheid waarin gerekend wordt.'],
['Account', 'Waar het staat: een wallet, een exchange, een kluis.'],
['Boeking', 'Eén gebeurtenis op een account: een aankoop, een verkoop, een overboeking of rente.'],
['Balans', 'Een vastgelegde stand op een datum. De app rekent vanaf die stand verder met de boekingen erna.'],
['Label', 'Een korte naam bij een beweging op de keten, opnieuw te gebruiken.'],
['Metaalproduct', 'Een munt of baar onder een metaal-asset, met het fijngewicht in gram.'],
] },
{ heading: 'De drie bronnen' },
'Alle drie heten ze bron. Ze beantwoorden verschillende vragen.',
{ list: [
['Tracking-bron', 'Welke wallet. Een xpub, een adres of een lightning-koppeling waarmee de app het saldo van één account uitleest. Een exchange of kluis heeft er geen; dat saldo komt uit de boekingen.'],
['Koersbron', 'Welke prijs. De dienst die de koers van een asset levert, met per asset en gebruik een rangorde en terugval.'],
['Verbinding', 'Welk adres. Waar de app zijn gegevens opvraagt: een eigen node of een publieke dienst. Eén verbinding bedient meerdere bronnen.'],
] },
],
},
bookings: {
title: 'Vier soorten boekingen',
body: [
'Elke boeking hoort bij een account en een asset. Er zijn vier soorten, elk met een eigen tabblad.',
{ list: [
['Aankoop en verkoop', 'Geld in ruil voor een asset of andersom, met hoeveelheid, prijs en kosten. Op {TRADES}.'],
['Verplaatsing', 'Hetzelfde asset van het ene account naar het andere, of naar een adres buiten de app. De kosten gaan af in het asset zelf. Op {TRANSFERS}.'],
['Rente en beloning', 'Wat erbij komt doordat iets wordt aangehouden: rente, staking, cashback. Een losse bijschrijving kan, of een afspraak waaruit de app de bijschrijvingen voorstelt. Op {INTEREST}.'],
['Balans', 'Geen beweging maar een vastgelegde stand op een datum. Op {VALUATIONS}.'],
] },
'Op {EVENT_ACCOUNTS} staat per account het saldo van nu. Een klik op een account opent alle vier samen, om te bekijken en te bewerken.',
],
},
anchors: {
title: 'Een balans is een anker',
body: [
'De stand van een account is de nieuwste balans plus de boekingen daarna. Boekingen van vóór die balans tellen voor de stand van nu niet meer mee.',
'Zo is een account bij te houden zonder elke beweging te boeken, bijvoorbeeld een spaarrekening of een kluis op saldo.',
{ heading: 'Een verschil' },
'Staan er ook boekingen vóór de balans, dan rekent de app na of die op dezelfde stand uitkomen. Is dat niet zo, dan staat het verschil op {ACTIONS}, met twee keuzes:',
{ list: [
['Boek', 'Het verschil wordt één aankoop of verkoop tegen de koers van dat moment.'],
['Aanvaard', 'Het verschil telt als geld dat de administratie in- of uitging, en niet als rendement. Terug te draaien.'],
] },
'Een balans zonder boekingen ervóór is een beginbalans: er is niets om tegen te vergelijken.',
{ term: 'Saldo vastleggen', text: 'in het venster van een account legt een balans vast op dit moment en niet op het begin van de dag. De knop staat standaard alleen bij geld; per asset is hij aan te zetten op {ASSETS}.' },
],
},
'free-value': {
title: 'Gratis binnengekomen: opbrengst of gift',
body: [
'Waarde die zonder betaling binnenkwam, boek je naar de reden en niet naar de manier waarop.',
{ list: [
['Opbrengst', 'Gekregen omdat je klant was of iets aanhield: rente, staking, cashback, een welkomstbonus. Boeken op {INTEREST}; het telt als resultaat.'],
['Gift', 'Iemand anders gaf het: een erfenis, een cadeau. Boeken als aankoop of verplaatsing, met de Herkomst op Gift. De app trekt de waarde op de dagkoers van het resultaat af.'],
] },
{ note: 'Een verkeerde keuze is achteraf niet aan de cijfers te zien.' },
'Een verplaatsing tussen twee eigen accounts is geen van beide: het totaal verandert niet.',
],
},
returns: {
title: 'Wat is rendement, wat is resultaat?',
body: [
'De getallen op {WEALTH} lijken op elkaar en meten iets anders. Ongerealiseerd en Ingelegd zijn een stand van nu; Rendement, Resultaat en Gerealiseerd gaan over de gekozen periode.',
{ figure: 'stand-period', caption: 'Ongerealiseerd en Ingelegd tellen sinds de aankoop; Rendement, Resultaat en Gerealiseerd alleen over de gekozen periode.' },
{ heading: 'Wat is rendement?' },
'Een percentage dat alleen de koers meet. Het is tijdgewogen: hoeveel en wanneer er gekocht is, telt niet mee. Het volgt de jaarkeuze. Het geldgewogen rendement, dat de stortingen wel meeweegt, staat op {TAXES}.',
{ heading: 'Wat is resultaat?' },
'Wat het bezit opleverde, als bedrag over de gekozen periode: de waardeverandering min wat er netto is ingelegd (aankopen erin, verkopen eruit). Koerswinst, de uitkomst van verkopen en rente tellen mee. Een gift telt als inbreng en niet als resultaat.',
{ heading: 'Wat is ongerealiseerd?' },
'De winst of het verlies op wat er nu is, als het nu verkocht werd: de waarde van nu min wat die eenheden gekost hebben, tegen de gemiddelde kostprijs inclusief kosten en premie. Een stand van nu: altijd sinds de aankoop, los van de jaarkeuze. Wat al verkocht is, telt niet mee.',
{ heading: 'Wat is gerealiseerd?' },
'De winst of het verlies op wat er in de gekozen periode verkocht is: de opbrengst min kosten en de kostprijs van de verkochte eenheden.',
{ heading: 'Wat is ingelegd?' },
'Per asset alle aankopen plus kosten, ook van wat al verkocht is. Na een verkoop is waarde min ingelegd dus niet meer hetzelfde als ongerealiseerd. In het jaaroverzicht staat Ingelegd (netto): per jaar de aankopen plus kosten, min wat verkopen opbrachten.',
{ heading: 'Wat is ingebracht (netto)?' },
'Hetzelfde voor een account: geld erin plus elke andere stroom, waaronder een verplaatsing vanaf een ander eigen account tegen de dagkoers van die dag. Rente telt niet als inbreng. De waarde min dit bedrag is het resultaat van het account.',
{ heading: 'Hoe ze samenhangen' },
{ figure: 'result-sum', caption: 'Het resultaat over alle jaren, verdeeld in zijn drie delen. De verhouding is een voorbeeld.' },
'Over alle jaren samen is het resultaat ongeveer ongerealiseerd plus gerealiseerd plus rente. Een gift is de uitzondering: die heeft kostprijs nul, dus bij Ongerealiseerd telt de hele waarde mee, terwijl het resultaat hem als inbreng tegen de dagkoers aftrekt.',
{ note: 'Ongerealiseerd en Resultaat kunnen een ander teken hebben en toch allebei kloppen: het eerste meet tegen de aankoop, het tweede over de gekozen periode. Per account komt dat vaak voor; zie Per account: ingebracht en resultaat.' },
],
},
'account-returns': {
title: 'Per account: ingebracht en resultaat',
body: [
'Op een accountkaart kan Ongerealiseerd negatief zijn terwijl het Resultaat positief is. Dat is geen fout: de twee meten vanaf een ander moment.',
{ list: [
['Ongerealiseerd', 'Bij een eigen account naar rato van de eenheden, tegen de gemiddelde kostprijs van het asset: binnen eigen vermogen kost een munt overal evenveel. Een account van derden heeft een eigen kostprijs, zie hieronder.'],
['Ingebracht (netto)', 'Geld erin plus elke andere stroom, waaronder een verplaatsing vanaf een ander eigen account tegen de dagkoers van die dag. Rente telt niet als inbreng.'],
['Resultaat', 'De waardeverandering min wat er is ingebracht. Over alle jaren is dat de ruimte tussen de twee lijnen op de kaart: de waarde en wat er is ingebracht.'],
['Rendement', 'Een verplaatsing tussen eigen accounts telt als opname op het ene en storting op het andere account, tegen de dagkoers. Alleen de koers en de kosten tellen.'],
] },
{ heading: 'Een voorbeeld' },
{ figure: 'account-example', caption: 'De waarde van nu ligt 4,77 onder de kostprijs van het asset, maar 31,51 boven wat er bij de verplaatsing binnenkwam. Het groene vlak is het resultaat.' },
'Munten op een exchange gekocht voor 191,20 in totaal, verplaatst naar een cold wallet toen ze 154,92 waard waren, en nu 186,43 waard. Ongerealiseerd is -4,77: onder wat ervoor betaald is. Ingebracht (netto) is 154,92 en het resultaat 31,51: gestegen sinds de munten op de cold wallet staan.',
'Ingelegd, de gemiddelde aankoopkoers en gerealiseerd zijn niet per account te verdelen. Die staan bij het asset.',
{ heading: 'Vermogen van derden' },
'Een account van een ander, bijvoorbeeld de spaarrekening van een kind, deelt de kostprijs niet met eigen vermogen. Een verplaatsing ernaartoe is een onttrekking tegen de dagkoers, en die waarde is de kostprijs van dat account. Zou de cold wallet hierboven van een ander zijn, dan is Ongerealiseerd daar 31,51: gemeten vanaf 154,92.',
'Wat de koers deed vóór de verplaatsing, blijft in het resultaat van eigen vermogen. Ook met Alles tonen blijven de twee gescheiden: in het venster van een asset staat het vermogen van derden op een eigen tab, met zijn eigen kengetallen.',
],
},
'two-balances': {
title: 'Twee bronnen voor één saldo',
body: [
'De app kent twee antwoorden op de vraag hoeveel er is. Voor bezit met een adres op de keten zijn ze gelijk.',
{ list: [
['Gescand saldo', 'Staat op {TRACKING}. De app leest de adressen van de wallet uit en telt op wat de keten daar meldt.'],
['Grootboek', 'Staat op {WEALTH} en op {TAXES}. De som van de boekingen: aankopen, verkopen, overboekingen en rente.'],
] },
{ heading: 'Waarom ze kunnen verschillen' },
{ list: [
['Geen adres', 'Een account bij een exchange of een bewaarder heeft geen xpub, dus er is niets om uit te lezen. Dat bezit staat alleen in het grootboek.'],
['Fee niet geboekt', 'De keten trekt de transactiekosten af, het grootboek niet.'],
['Boeking zonder adres', 'Telt mee op {WEALTH}, niet op {TRACKING}.'],
] },
{ heading: 'Wat eraan te doen is' },
{ list: [
'Bewegingen op de keten zonder boeking komen als voorstel op {ACTIONS}. Na goedkeuring lopen de twee gelijk.',
'Is de oorzaak niet te vinden, leg dan een balans vast. De app neemt die stand als waarheid en telt de boekingen daarna erbij op.',
] },
],
},
wealth: {
title: 'Positie, resultaat en rendement',
body: [
'Per asset of per account de waarde van nu, het resultaat en het rendement. Hier wordt niets geboekt; dat gebeurt bij de boekingen.',
{ heading: 'Wat waar staat' },
{ list: [
['Assetkaart', 'Hoeveelheid, Waarde, Aandeel, Ongerealiseerd, Resultaat en Rendement, en Niet verklaard als een balans afwijkt van de boekingen.'],
['Accountkaart', 'Hetzelfde zonder Hoeveelheid. De accountkaarten tellen op tot het totaal.'],
['Venster van een asset', 'Op de tab Eigen vermogen Gem. aankoopkoers, Gem. premie en Ingelegd, per account. Met vermogen van derden in beeld staan dezelfde kengetallen van de derden op de tab Vermogen van derden. Op het tabblad Rendement het rendement, het resultaat en gerealiseerd per jaar.'],
['Paginakolom', 'Ingelegd, Ongerealiseerd, Resultaat en Rendement van het geheel.'],
] },
{ term: 'Plus en min', text: 'een groen of rood getal heeft geen plusteken; het minteken staat er altijd. Zonder kleur, zoals op het rendementswidget van het dashboard, staan plus en min er allebei.' },
{ list: [
['Gem. aankoopkoers', 'Wat er per eenheid betaald is, zonder kosten.'],
['Gem. premie', 'De kosten per aankoop, bij metaal inclusief de premie.'],
] },
'Wat Ongerealiseerd, Resultaat, Rendement en Ingelegd precies meten, staat bij de begrippen onder Zie ook.',
{ heading: 'Eigen vermogen en vermogen van derden' },
'Een account van een ander of een account dat niet meetelt, staat zo in de kolom Vermogen op {ACCOUNTS}. Vermogen en het dashboard tonen dan alleen eigen vermogen; de schakelaar Alles tonen bij het totaal toont alles.',
],
},
taxes: {
title: 'Belastingen: kengetallen, maar geen aangifte',
body: [
'De pagina levert de getallen voor een aangifte. De belasting zelf rekent de app niet uit; dat doet het land van de aangifte.',
{ list: [
['Waarde per peildatum', 'Per account en asset op 1 januari van elk jaar, met een kopieerknop per bedrag.'],
['Categorie', 'Banktegoeden en overige bezittingen apart, voor een aangifte die daarom vraagt. Verschijnt alleen als er op een account of asset een categorie gekozen is.'],
['Resultaat', 'Per jaar begin, eind, stortingen, onttrekkingen en rendement. Dit rendement is geldgewogen: het weegt mee wanneer er geld in- of uitging.'],
] },
'Wat meetelt, staat in de kolom Belastingen op {ACCOUNTS} en {ASSETS}. Een keuze op het account gaat voor die op het asset.',
'Gaat de oudste boeking verder terug dan nodig, zet dan het startjaar op {PORTFOLIOS}. Dat haalt kolommen weg en geen boekingen: de stand in de eerste kolom blijft volledig.',
],
},
actions: {
title: 'Wat op een beslissing wacht',
body: [
'Alles wat op een beslissing wacht, staat op één plek. De kolom links zegt per blok hoeveel er openstaat.',
{ list: [
['AI-voorstellen', 'Wat de AI wil vastleggen. Het gebeurt pas na goedkeuring.'],
['Bewegingen', 'Een beweging op de keten zonder boeking, als voorstel: aankoop, verplaatsing of negeren.'],
['Rente te boeken', 'Bijschrijvingen die uit een rente-afspraak volgen.'],
['Verschillen', 'Een balans die niet uitkomt op de boekingen ervóór.'],
] },
],
},
'price-sources': {
title: 'Koersbronnen en de bronkeuze',
body: [
'Een koersbron is de dienst die de koers van een asset levert. Welke bron dat is, staat per asset en per gebruik op {SOURCE_PROPOSALS}: een rangorde, en valt de eerste weg, dan neemt de volgende het over.',
'Gebruik betekent waar de koers voor dient. De koers op een peildatum voor {TAXES} kan uit een andere bron komen dan de koers van nu.',
{ list: [
['Nieuw asset', 'Krijgt meteen zijn standaardbron, als die er is. Een andere keuze maak je op {SOURCE_PROPOSALS}.'],
['Sleutels', 'Een bron die een API-sleutel vraagt, krijgt die op {PROVIDER_KEYS}. De sleutel blijft op de server; de browser ziet hem niet.'],
['Dekking', 'Een bron levert alleen de assets die hij kent. Voor een asset buiten zijn dekking slaat de app hem over.'],
['Met de hand', 'Een zelf ingevoerde koers, voor iets zonder markt of een dag die een bron mist.'],
] },
'Op {PRICE_HISTORY} staat per asset welke koersen er zijn en uit welke bron ze komen.',
],
},
tracking: {
title: 'Wallets volgen on-chain',
body: [
'Een tracking-bron is een xpub of een los adres onder een account. De app leest daarmee uit wat er op de keten gebeurt. Koppelen gaat op {ACCOUNTS}.',
{ list: [
['Bijwerken', 'Op {TRACKING} leest de app de adressen opnieuw uit. Elke beweging staat per wallet, als ontvangen of verzonden, met de fee.'],
['Voorstel', 'Een beweging zonder boeking wordt een voorstel op {ACTIONS}. Na goedkeuring staat erbij dat hij ook als gebeurtenis geregistreerd is, en telt hij mee bij Gekoppeld.'],
['Label', 'Een korte naam bij een beweging, opnieuw te gebruiken.'],
] },
'Een account bij een exchange of in een kluis heeft geen tracking-bron. Het saldo daarvan komt uit de boekingen.',
],
},
connections: {
title: 'Verbindingen: waar de app gegevens opvraagt',
body: [
'Een verbinding is een adres waar de app gegevens opvraagt. Per soort staat er één actief; Test controleert of het adres antwoordt.',
{ list: [
['Bitcoin-explorer', 'Leest adressen en transacties voor {TRACKING} en levert de koers van mempool. Standaard een publieke dienst. Met een eigen node gaan de adressen niet naar een derde, en loopt een xpub-scan niet tegen een limiet aan.'],
['Lightning-node', 'LND. Leest de lightning-kant van een eigen node. De app praat via zijn eigen server met de node; macaroon en certificaat blijven op die server.'],
] },
'Een eigen explorer en lightning horen bij de volledige versie. De standaard-explorer werkt altijd.',
],
},
lnd: {
title: 'Een LND-node koppelen',
body: [
'Gebruik een macaroon die alleen mag lezen. Die maak je op de node met:',
{ code: 'lncli bakemacaroon info:read offchain:read invoices:read onchain:read peers:read' },
'Plak de uitkomst bij Macaroon. Hex mag, base64 ook.',
{ note: 'Niet de macaroon uit een lndconnect-URL: dat is doorgaans de admin-macaroon, en daarmee kan betaald worden vanaf de node.' },
{ list: [
['tls.cert', 'Met het certificaat erbij controleert de app dat hij met de eigen node praat. Zonder is de verbinding versleuteld maar niet gecontroleerd; aanvaardbaar over een tunnel of het eigen netwerk.'],
['Adres', 'Een kaal ip-adres met poort mag; de app maakt er een https-adres van.'],
['Timeout', 'De node is niet bereikbaar. Controleer eerst of de tunnel aanstaat.'],
] },
],
},
ai: {
title: 'De AI-koppeling',
body: [
'Een AI-programma op deze computer kan de app lezen en voorstellen doen. Het programma start de koppeling zelf; er is geen webadres en geen wachtwoord nodig. De koppeling hoort bij de volledige versie.',
{ heading: 'Wat de AI mag' },
{ list: [
['Actief', 'De koppeling zelf. Uit wordt elke aanroep geweigerd en stopt een gestart AI-programma.'],
['Lezen', 'De administratie bekijken.'],
['Schrijven', 'Iets toevoegen of wijzigen, altijd als voorstel op {ACTIONS} dat eerst goedgekeurd moet worden.'],
['Navigatie', 'Het scherm naar een andere pagina brengen, na een aftelling met een stopknop.'],
['Onbewerkt delen', 'Uit ziet de AI verwijzingen en marges in plaats van namen en bedragen.'],
] },
{ note: 'Onbewerkt delen alleen aanzetten in een portefeuille zonder echte gegevens.' },
'Daaronder bepalen vier schuiven hoe precies de AI de cijfers ziet: percentages, totalen, bedragen en hoeveelheden, als marge of exact.',
'Elke aanroep staat op {AI_ACTIVITY}, met de gegevens die daarbij de app verlieten. De instellingen gelden per portefeuille en meteen, zonder herstart.',
],
},
interest: {
title: 'Rente-afspraken',
body: [
'Een rente-afspraak legt vast wat een account oplevert: percentage, ritme en looptijd. De app rekent daaruit de bijschrijvingen en zet ze klaar op {ACTIONS}.',
{ list: [
['Hoofdsom', 'Leeg volgt het saldo van het account; dat past bij een spaarrekening. Bij een deposito hoort hier het startbedrag.'],
['Rente op rente', 'Alleen bij een vaste hoofdsom: elke periode rekent dan ook over de eerdere bijschrijvingen. Een hoofdsom die het saldo volgt, doet dat al vanzelf.'],
['Renteperioden', 'Verandert het percentage, leg dan een periode vast met de ingangsdatum.'],
['Volgorde', 'Bijschrijvingen worden op volgorde geboekt: elke bijschrijving verhoogt het saldo waarover de volgende rekent.'],
] },
'Een losse bijschrijving zonder afspraak kan ook, op {INTEREST}.',
],
},
health: {
title: 'Gezondheid: controles op de administratie',
body: [
'Controle uitvoeren loopt tien controles na op de administratie zelf. Dat gebeurt alleen op verzoek, omdat het even kan duren.',
{ list: [
'Dubbele boekingen, en verplaatsingen zonder tegenkant.',
'Balansen die niet verzoend zijn.',
'Gaten, achterstand en verdachte koersen in de koershistorie.',
] },
'Een bevinding staat als waarschijnlijk niet bedoeld of als ter kennisgeving. Is hij wel bedoeld, dan haalt Dit klopt zo hem weg; hij komt terug zodra er iets verandert.',
'Herstellen gebeurt elders: bij de boekingen, op {ACTIONS} of op {PRICE_HISTORY}.',
],
},
dashboard: {
title: 'Het dashboard inrichten',
body: [
'Het dashboard bestaat uit widgets, zelf samen te stellen, in één of meer indelingen.',
{ list: [
['Bewerken', 'Lang indrukken op een widget, of het knopje {icon:adjust} naast Dashboard in het menu; dat verschijnt als de muis erboven staat. Escape, een klik buiten het raster of hetzelfde knopje zet de bewerkmodus weer uit.'],
['Toevoegen', 'In de bewerkmodus opent het lege vak het menu met widgets.'],
['Schikken', 'Een widget verslepen, of zijn breedte aanpassen in het menu in zijn kop.'],
['Indelingen', 'Meerdere bewaarde dashboards om tussen te wisselen. In de bewerkmodus staan ze als lijst, met knoppen voor een nieuwe indeling, een andere naam en verwijderen.'],
] },
'Standaard verwijderen begint hem opnieuw, met de indeling waarmee de app begon.',
],
},
'price-history': {
title: 'Prijsgeschiedenis: waar elke koers vandaan komt',
body: [
'Per asset een kaart met de herkomst van elke dagkoers over het afgelopen jaar. Een klik opent het venster met onder meer de herkomst, de gaten en de koersen zelf.',
{ heading: 'Wat de app over een dagkoers weet, van sterk naar zwak' },
{ list: [
['Slotkoers', 'De koers van de bron aan het eind van die dag.'],
['Met de hand', 'Een zelf ingevoerde koers.'],
['Vandaag', 'De actuele koers, die nog beweegt.'],
['Afgeleid', 'Uit een spot van de bron, geen slotkoers.'],
['Doorgetrokken', 'Een eerdere koers, met erbij hoeveel dagen oud.'],
['Geen koers', 'Telt als nul mee. Een bedrag waarin dat speelt, krijgt een gele i.'],
] },
{ heading: 'Gaten' },
'Een gat is een reeks dagen zonder koers. Bijwerken vraagt de ontbrekende koersen op bij de bron. Een dag die geen enkele bron heeft, is te negeren of met de hand in te vullen.',
{ note: 'Bij een bron met een maandlimiet kost bijwerken een verzoek per dag.' },
],
},
charts: {
title: 'Signalen en grafieken',
body: [
'Drie signalen voor bitcoin, elk als lampje in de kolom. Groen, geel en rood zijn hier een oordeel; een klik op een lampje opent die grafiek.',
{ list: [
['Power Law', 'De koers tegen de langetermijntrend, in log-log. De lijn is een fit op de historie, geen voorspelling; de banden erom zijn een vaste factor.'],
['Weeks-gemiddelden', 'Het 200-weeks gemiddelde is de trage bodemzone van de cyclus. Het 50-weeks gemiddelde is de scheidslijn tussen een stijgende en een dalende markt.'],
['Mayer Multiple', 'De koers gedeeld door het 200-daags gemiddelde. Onder 1,0 is historisch een zeldzame lage zone, boven 2,4 oververhit.'],
] },
'Elke grafiek kan lineair of logaritmisch, inzoomen en op volledig scherm.',
],
},
conversion: {
title: 'Conversies',
body: [
'Een rekenblad tussen geld en de eenheden van bitcoin: BTC, mBTC, bit, sat en msat. Elk veld rekent de andere live door. Er wordt niets vastgelegd.',
],
},
assets: {
title: 'Assets en metalen',
body: [
'Een asset is iets in bezit waarvan de app een koers bijhoudt, met symbool, naam, soort, hoofdeenheid, ondereenheid en decimalen.',
{ list: [
['Soort', 'Crypto, metaal of valuta. De soort bepaalt de standaardcategorie op {TAXES} en of Saldo vastleggen aanstaat; per asset is dat anders in te stellen.'],
['Metaal', 'Rekent in gram. Een koers per troy ounce rekent de app om naar gram.'],
['Munten en baren', 'Producten onder een metaal-asset, met hun fijn gewicht. Een aankoop van een product boekt het aantal stuks; het gewicht rekent de app uit.'],
['Verwijderen', 'Kan pas als geen boeking meer naar het asset verwijst.'],
] },
],
},
accounts: {
title: 'Accounts',
body: [
'Een account is waar iets staat: een wallet, een exchange, een kaart, een bank, een kluis of een lightning-wallet.',
{ list: [
['Tracking-bron', 'Een xpub of adres onder het account laat de app het saldo op de keten uitlezen.'],
['Vermogen', 'Of het eigen vermogen is, beheerd voor een ander, of niet meetelt.'],
['Belastingen', 'Of het in de aangifte meetelt. Staat los van Vermogen.'],
['Afsluiten', 'Een afgesloten account verdwijnt uit de keuzelijsten, maar telt in saldi, standen en belastingen gewoon mee.'],
['Samenvoegen', 'Twee accounts die hetzelfde zijn, worden er één.'],
] },
],
},
license: {
title: 'Gratis en volledige versie',
body: [
'De gratis versie is onbeperkt in tijd, met een kleine administratie: één asset, één fiatrekening, één balans en één portefeuille.',
'Koppelingen, de bronkeuze, eigen sleutels en de belastingpagina horen bij de volledige versie. Lezen en exporteren blijven altijd werken; er wordt nooit iets weggehaald.',
'Het licentiebestand gaat op {LICENSE} en geldt meteen. Elk slot elders in de app verwijst hierheen.',
],
},
portfolios: {
title: 'Portefeuilles',
body: [
'Elke portefeuille is een aparte administratie, met een eigen database en eigen geheimen. Er is er altijd één open.',
{ list: [
['Rekenvaluta', 'Gekozen bij het aanmaken en daarna vast. Alle bedragen staan in die munt.'],
['Wisselen', 'De app start daarvoor opnieuw.'],
['Verwijderen', 'Verplaatst de portefeuille naar het archief. Pas daar kan hij definitief weg.'],
] },
{ note: 'Een back-up bevat database en geheimen samen: alleen samen kloppen ze.' },
],
},
},
},
};
+11
View File
@@ -0,0 +1,11 @@
// Generated, do not edit. sha256:9378a9bd304180d5b6df45747d1f56b526c6fa63df0636a0d2d53e119fdedfff
import { sharedText } from './i18n/index.js';
export function placeLabel(naam, code) {
const tekst = placeActions(code)[naam];
if (!tekst) return naam;
const eind = tekst.search(/[.:]/);
return (eind > 0 ? tekst.slice(0, eind) : tekst).trim();
}
export const placeActions = (code) => sharedText(code).places;
+152
View File
@@ -0,0 +1,152 @@
// Generated, do not edit. sha256:4249f91eb8232d410f76bd1c74b9d6d6491317d71d4cb8ae4ce4ed0b5bdfa51b
import { CONFIG } from './config.js';
const DAY_MS = 24 * 60 * 60 * 1000;
export const PROVENANCE_KINDS = Object.freeze(['close', 'manual', 'live', 'spot', 'carried', 'none']);
const dayOf = iso => String(iso).slice(0, 10);
const msOf = day => Date.parse(`${day}T00:00:00Z`);
const addDays = (day, n) => new Date(msOf(day) + n * DAY_MS).toISOString().slice(0, 10);
export function dailyProvenance(rows, opts = {}) {
const { assetKind = 'crypto', manualProviderId = null } = opts;
const today = opts.today || new Date().toISOString().slice(0, 10);
const perDay = new Map();
for (const r of rows || []) {
if (r == null || r.fiatPrice == null || !(r.fiatPrice > 0)) continue;
const day = dayOf(r.observedAt);
const cur = perDay.get(day);
perDay.set(day, cur && beats(cur, r, day === today) ? cur : r);
}
if (perDay.size === 0) return [];
const days = [...perDay.keys()].sort();
const from = opts.from || days[0];
const until = opts.until || today;
if (from > until) return [];
const kindOf = (r, day) => kindFor(r, day, manualProviderId, today);
const limit = CONFIG.PRICE_HISTORY.CARRY_FORWARD_DAYS[assetKind];
const out = [];
let last = null;
for (const d of days) if (d < from) last = { day: d, row: perDay.get(d) };
for (let day = from; day <= until; day = addDays(day, 1)) {
const row = perDay.get(day);
if (row) {
last = { day, row };
out.push({ day, fiatPrice:row.fiatPrice, kind: kindOf(row, day), row, ageDays: 0, deviation: null });
continue;
}
const age = last ? Math.round((msOf(day) - msOf(last.day)) / DAY_MS) : null;
if (last && (limit == null || age <= limit)) {
out.push({ day, fiatPrice:last.row.fiatPrice, kind: 'carried', row: last.row, ageDays: age, deviation: null });
} else {
out.push({ day, fiatPrice:null, kind: 'none', row: null, ageDays: age, deviation: null });
}
}
addDeviations(out);
return out;
}
function kindFor(r, day, manualProviderId, today) {
if (manualProviderId != null && r.providerId === manualProviderId) return 'manual';
if (day === today) return 'live';
return r.resolution !== 'timestamp' && !isProvisional(r) ? 'close' : 'spot';
}
export function provenanceOfRow(row, opts = {}) {
if (!row || !(row.fiatPrice > 0)) return null;
const today = opts.today || new Date().toISOString().slice(0, 10);
const voor = opts.day || today;
const dag = dayOf(row.observedAt);
const ageDays = Math.max(0, Math.round((msOf(voor) - msOf(dag)) / DAY_MS));
const kind = ageDays > 0 ? 'carried'
: (row.derivedFrom === 'spot' && dag !== today ? 'spot' : kindFor(row, dag, opts.manualProviderId ?? null, today));
return { day: voor, fiatPrice:row.fiatPrice, kind, row, ageDays, deviation: null };
}
export function provenanceOn(rows, day, opts = {}) {
const today = opts.today || new Date().toISOString().slice(0, 10);
const tot = addDays(day, 31) < today ? addDays(day, 31) : today;
const d = dailyProvenance(rows, { ...opts, today, from: addDays(day, -31), until: tot < day ? day : tot })
.find(x => x.day === day) || null;
const used = opts.usedPrice;
if (d && d.fiatPrice == null && used != null) {
const eerder = (rows || []).filter(r => r && r.fiatPrice > 0 && dayOf(r.observedAt) <= day && gelijk(r.fiatPrice, used))
.sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))[0];
if (!eerder) return d;
return { day, fiatPrice:eerder.fiatPrice, kind: 'carried', row: eerder, deviation: null,
ageDays: Math.round((msOf(day) - msOf(dayOf(eerder.observedAt))) / DAY_MS) };
}
if (!d || used == null || d.fiatPrice == null || gelijk(d.fiatPrice, used)) return d;
const bronDag = dayOf(d.row.observedAt);
const alt = (rows || []).find(r => r && r.fiatPrice > 0 && dayOf(r.observedAt) === bronDag && gelijk(r.fiatPrice, used));
if (!alt) return d;
const kind = d.kind === 'carried' ? 'carried' : kindFor(alt, day, opts.manualProviderId ?? null, today);
return { ...d, fiatPrice:alt.fiatPrice, row: alt, kind };
}
const gelijk = (a, b) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
function isProvisional(r) {
const written = r.updatedAt || r.createdAt;
return !!written && dayOf(written) <= dayOf(r.observedAt);
}
const seenAt = r => (r.resolution === 'timestamp' ? r.observedAt : (r.updatedAt || r.createdAt || ''));
function beats(cur, cand, isToday = false) {
if (isToday) return seenAt(cur) >= seenAt(cand);
const curDay = cur.resolution !== 'timestamp', candDay = cand.resolution !== 'timestamp';
if (curDay !== candDay) return curDay;
if (curDay) return (cur.id ?? 0) >= (cand.id ?? 0);
return cur.observedAt >= cand.observedAt;
}
function addDeviations(out) {
const real = k => k === 'close' || k === 'manual';
let before = null;
const pct = (v, ref) => (ref ? Math.round(((v - ref) / ref) * 10000) / 100 : null);
for (const d of out) {
if (real(d.kind)) before = d.fiatPrice;
else if (d.kind === 'spot') d.deviation = { before: pct(d.fiatPrice, before), after: null };
}
let after = null;
for (let i = out.length - 1; i >= 0; i--) {
const d = out[i];
if (real(d.kind)) after = d.fiatPrice;
else if (d.kind === 'spot') d.deviation.after = pct(d.fiatPrice, after);
}
}
export function provenanceRuns(days) {
const runs = [];
for (const d of days || []) {
const last = runs[runs.length - 1];
if (last && last.kind === d.kind) { last.until = d.day; last.days += 1; }
else runs.push({ kind: d.kind, from: d.day, until: d.day, days: 1 });
}
return runs;
}
export function originFrom(d, names = new Map()) {
if (!d || d.fiatPrice == null || !d.row) return null;
const r = d.row;
return {
day: d.day, kind: d.kind, fiatPrice:d.fiatPrice, ageDays: d.ageDays, deviation: d.deviation,
source: names.get(r.providerId) || null,
sourceDay: dayOf(r.observedAt),
moment: d.kind === 'spot' || d.kind === 'live'
? (r.resolution === 'timestamp' ? r.observedAt : (r.updatedAt || r.createdAt || null)) : null,
};
}
export function liveOrigin(fiatPrice, source = null, now = new Date()) {
if (fiatPrice == null) return null;
const nu = now.toISOString();
return { day: nu.slice(0, 10), kind: 'live', fiatPrice, ageDays: 0, deviation: null, source, sourceDay: null, moment: nu };
}
export function provenanceCounts(days) {
const counts = Object.fromEntries(PROVENANCE_KINDS.map(k => [k, 0]));
for (const d of days || []) counts[d.kind] += 1;
return counts;
}
+15
View File
@@ -0,0 +1,15 @@
// Generated, do not edit. sha256:afb8f908930f6e36cf200b71f053da053bdc63adc393178a3cdc212216d2ca98
export function createRegistry(name, { validate } = {}) {
const items = new Map();
return Object.freeze({
name,
register(entry) {
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error(`${name}: an entry needs an id`);
if (items.has(entry.id)) throw new Error(`${name}: '${entry.id}' is registered twice`);
validate?.(entry);
items.set(entry.id, Object.freeze({ feature: null, ...entry }));
},
get: id => items.get(id) ?? null,
list: () => [...items.values()],
});
}
+7
View File
@@ -0,0 +1,7 @@
// Generated, do not edit. sha256:2f66375ae83caf2754cb28e585e1e0f78a5f72502aadfc7743433f4d0f72c85c
const DIAKRITISCH = /\p{Diacritic}/gu;
export function toSearchName(value) {
if (value === null || value === undefined) return '';
return String(value).normalize('NFD').replace(DIAKRITISCH, '').toLowerCase().trim();
}
+47
View File
@@ -0,0 +1,47 @@
// Generated, do not edit. sha256:e34176fb1b8095153c2f85bcad4807088cce9195b029b64bb8721d0f3610d90d
export const HISTORY_CAPABILITIES = Object.freeze(['daily_history', 'history_point']);
const has = (p, cap) => Array.isArray(p.endpoints) && p.endpoints.some(e => e.capability === cap);
const covers = (p, symbol) => Array.isArray(p.coverage) && p.coverage.includes(symbol);
const deliversHistory = p => HISTORY_CAPABILITIES.some(cap => has(p, cap));
export function diagnoseSources(asset, { providers = [], historyChain = [], usage = {}, serviceCovers = false } = {}) {
if (!asset) return [];
const byKey = new Map(providers.map(p => [p.key, p]));
const candidates = providers.filter(p => !p.isSystem && !p.fixedRate && covers(p, asset.symbol) && deliversHistory(p));
if (!candidates.length) return serviceCovers ? [] : [{ severity: 'problem', code: 'noCoverage' }];
const active = candidates.filter(p => p.isActive);
if (!active.length) return serviceCovers ? [] : [{ severity: 'problem', code: 'allInactive', names: candidates.map(p => p.name) }];
const chosen = [], unusable = [];
for (const key of historyChain) {
const p = byKey.get(key);
if (!p) continue;
const usable = p.isActive && deliversHistory(p) && covers(p, asset.symbol);
(usable ? chosen : unusable).push(p);
}
const unique = list => [...new Map(list.map(p => [p.id, p])).values()];
if (!chosen.length) {
if (serviceCovers) return [];
const proposal = proposalFrom(active);
return [{ severity: 'problem', code: unusable.length ? 'choiceUnusable' : 'noChoice',
names: unique(unusable).map(p => p.name), proposal }];
}
const out = [];
const atLimit = unique(chosen).filter(p => p.callsPerMonth && (usage[p.id] ?? 0) >= p.callsPerMonth);
if (atLimit.length) {
out.push({ severity: atLimit.length === unique(chosen).length && !serviceCovers ? 'problem' : 'attention',
code: 'limitReached', names: atLimit.map(p => p.name) });
}
const off = unique(unusable).filter(p => !p.isActive);
if (off.length) out.push({ severity: 'attention', code: 'choiceInactive', names: off.map(p => p.name) });
return out;
}
function proposalFrom(active) {
for (const cap of HISTORY_CAPABILITIES) {
const p = active.find(x => has(x, cap));
if (p) return { providerKey: p.key, providerId: p.id, providerName: p.name, capability: 'history' };
}
return null;
}
+77
View File
@@ -0,0 +1,77 @@
// Generated, do not edit. sha256:ca177447c22d0d66351f016a9d6a55b9929893b9ec7e4207164d9f1592938144
export const ZONE = 'Europe/Amsterdam';
function offsetMinutes(momentMs) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: ZONE, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
const d = Object.fromEntries(fmt.formatToParts(new Date(momentMs))
.filter(p => p.type !== 'literal').map(p => [p.type, p.value]));
const uur = d.hour === '24' ? '00' : d.hour;
return (Date.UTC(+d.year, +d.month - 1, +d.day, +uur, +d.minute, +d.second) - momentMs) / 60000;
}
export function toUtcIso(text) {
if (text === null || text === undefined || text === '') return text;
if (/Z$/.test(text)) return text;
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(text)) return text;
const alsofUtc = Date.parse(text + 'Z');
if (Number.isNaN(alsofUtc)) throw new Error(`Onleesbaar tijdstempel: ${text}`);
const schatting = alsofUtc - offsetMinutes(alsofUtc) * 60000;
return new Date(alsofUtc - offsetMinutes(schatting) * 60000).toISOString().replace(/\.000Z$/, 'Z');
}
export function toLocalIso(iso) {
if (iso === null || iso === undefined || iso === '') return iso;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) throw new Error(`Onleesbaar tijdstempel: ${iso}`);
return new Date(ms + offsetMinutes(ms) * 60000).toISOString()
.replace(/\.000Z$/, '').replace(/Z$/, '');
}
export function localDate(iso) {
const lokaal = toLocalIso(iso);
return lokaal ? String(lokaal).slice(0, 10) : '';
}
export function localDateNl(iso) {
return dayNl(localDate(iso));
}
let dateTag = 'nl-NL';
let dateFmt = null;
export function setDateLocale(tag) {
if (tag !== dateTag) { dateTag = tag; dateFmt = null; }
}
export function dayNl(day) {
if (!day) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
if (!m) return String(day);
dateFmt ??= new Intl.DateTimeFormat(dateTag, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' });
return dateFmt.format(Date.UTC(+m[1], +m[2] - 1, +m[3]));
}
export function localDateTimeNl(iso) {
const lokaal = toLocalIso(iso);
return lokaal ? `${dayNl(String(lokaal).slice(0, 10))} ${String(lokaal).slice(11, 16)}` : '';
}
export function localTime(iso) {
const lokaal = toLocalIso(iso);
return lokaal ? String(lokaal).slice(11, 16) : '';
}
export function localDateTime(iso) {
const lokaal = toLocalIso(iso);
return lokaal ? `${String(lokaal).slice(0, 10)} ${String(lokaal).slice(11, 16)}` : '';
}
export function nowUtcIso() {
return new Date().toISOString();
}
export function nowLocalIso() {
return toLocalIso(new Date().toISOString());
}
+117
View File
@@ -0,0 +1,117 @@
// Generated, do not edit. sha256:20efe63e56f8374f20930043f99fa07bae78df1e6fc48a984d3f6364d25853bf
import { CONFIG } from './config.js';
import { fail } from './app-error.js';
const M = CONFIG.METALS;
export const isMetal = asset => !!asset && asset.kind === 'metal';
export function metalFactor(code) {
const f = M.UNITS[code];
if (!f) throw new Error(`Unknown metal unit: '${code}'.`);
return f;
}
export function metalPrimary(asset) {
return asset.displayUnit === 'subunit' && asset.subunit ? asset.subunit : asset.unit;
}
export function metalSecondary(asset) {
return asset.displayUnit === 'subunit' && asset.subunit ? asset.unit : asset.subunit;
}
export function toBaseByFactor(value, code) {
const n = Number(value);
if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.');
return Math.round(n * metalFactor(code) * Math.pow(10, M.BASE_DECIMALS));
}
export function fromBaseByFactor(amount, code) {
return (Number(amount) / Math.pow(10, M.BASE_DECIMALS)) / metalFactor(code);
}
export function mainToSub(main, decimals) {
return Math.round(Number(main) * Math.pow(10, decimals || 0));
}
export function subToMain(sub, decimals) {
const d = decimals || 0;
return Number((Number(sub) / Math.pow(10, d)).toFixed(d));
}
export function toSubunit(value, asset) {
if (isMetal(asset)) return toBaseByFactor(value, metalPrimary(asset));
const n = Number(value);
if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.');
const dec = asset.decimals ?? 0;
return asset.displayUnit === 'subunit' ? Math.round(n) : mainToSub(n, dec);
}
export function fromSubunit(amount, asset) {
if (isMetal(asset)) return fromBaseByFactor(amount, metalPrimary(asset));
const dec = asset.decimals ?? 0;
if (asset.displayUnit === 'subunit') return amount;
return subToMain(amount, dec);
}
export function unitToSubunit(value, asset) {
if (isMetal(asset)) return toBaseByFactor(value, asset.unit);
const n = Number(value);
if (!Number.isFinite(n)) fail('common.invalidAmount', null, 'Invalid amount.');
return mainToSub(n, asset.decimals ?? 0);
}
export function priceToBase(price, asset) {
const n = Number(price);
return isMetal(asset) ? n / metalFactor(metalPrimary(asset)) : n;
}
export function priceFromBase(stored, asset) {
const n = Number(stored);
return isMetal(asset) ? n * metalFactor(metalPrimary(asset)) : n;
}
export function unitPriceToBase(price, asset) {
const n = Number(price);
if (!Number.isFinite(n)) throw new Error('Invalid price.');
return isMetal(asset) ? n / metalFactor(asset.unit) : n;
}
function getal(v) {
if (v === null || v === undefined) return null;
if (typeof v === 'string' && v.trim() === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function feeSigned(fee, type) {
const f = getal(fee) ?? 0;
return type === 'sell' ? -f : f;
}
export function totalFromPrice(amountBase, decimals, priceBase, fee = 0, type = 'buy') {
const a = getal(amountBase), p = getal(priceBase);
if (a === null || p === null) return null;
return (a / Math.pow(10, decimals ?? 0)) * p + feeSigned(fee, type);
}
export function priceFromTotal(total, amountBase, decimals, fee = 0, type = 'buy') {
const tot = getal(total), a = getal(amountBase);
if (tot === null || a === null) return null;
const eenheden = a / Math.pow(10, decimals ?? 0);
const netto = tot - feeSigned(fee, type);
if (netto < 0) return null;
return eenheden > 0 ? netto / eenheden : null;
}
export function amountFromTotal(total, priceBase, decimals, fee = 0, type = 'buy') {
const tot = getal(total), p = getal(priceBase);
if (tot === null || p === null || p <= 0) return null;
const netto = tot - feeSigned(fee, type);
if (netto < 0) return null;
return (netto / p) * Math.pow(10, decimals ?? 0);
}
export function priceUnitLabel(asset) {
return isMetal(asset) ? metalPrimary(asset) : asset?.unit;
}
export function hasSubunit(asset) {
return !!(asset && typeof asset.subunit === 'string' && asset.subunit.trim() !== '');
}
+53
View File
@@ -0,0 +1,53 @@
// Generated, do not edit. sha256:d8050520cd6294fe603e71def64a9f6a61433bedfd9e055fc63f536c99905b1a
export function assetMetrics(positie, price) {
const div = Math.pow(10, positie.decimals);
const units = positie.net / div;
const value = price != null ? units * price : null;
const avgPrice = positie.buy_amount ? positie.buy_spot_raw / positie.buy_amount : null;
const avgCost = avgCostOf(positie);
const paidUnits = paidUnitsOf(positie);
const costAtAvg = avgCost != null ? paidUnits * avgCost : (paidUnits > 0 ? null : 0);
return {
units,
value,
avgPrice,
avgCost,
unrealized: (value != null && costAtAvg != null) ? value - costAtAvg : null,
avgPremium: positie.buy_events ? positie.buy_fee_total / positie.buy_events : null,
invested: (positie.buy_spot_raw / div) + positie.buy_fee_total,
};
}
export function saleResult(positie, sale) {
const div = Math.pow(10, positie.decimals);
const verkocht = (Number(sale.amount) || 0) / div;
const opbrengst = verkocht * (Number(sale.fiatPrice) || 0) - (Number(sale.fiatFee) || 0);
const weg = verkocht + (Number(sale.unitFee) || 0) / div;
const kostEenheden = Math.min(weg, paidUnitsOf(positie));
const avgCost = avgCostOf(positie);
if (avgCost == null) return kostEenheden > 0 ? null : opbrengst;
return opbrengst - kostEenheden * avgCost;
}
export function feeResult(positie, unitFee) {
return saleResult(positie, { amount: 0, fiatPrice: 0, fiatFee: 0, unitFee });
}
export function combinePositions(rows) {
const som = { decimals: rows[0]?.decimals ?? 0, net: 0, buy_amount: 0, buy_spot_raw: 0, buy_fee_total: 0, buy_events: 0, gift_amount: 0 };
for (const r of rows) {
for (const k of ['net', 'buy_amount', 'buy_spot_raw', 'buy_fee_total', 'buy_events', 'gift_amount']) som[k] += Number(r[k]) || 0;
}
return som;
}
function avgCostOf(positie) {
const div = Math.pow(10, positie.decimals);
return positie.buy_amount
? (positie.buy_spot_raw + (positie.buy_fee_total || 0) * div) / positie.buy_amount : null;
}
function paidUnitsOf(positie) {
const div = Math.pow(10, positie.decimals);
return Math.max(0, positie.net / div - (positie.gift_amount || 0) / div);
}
+4
View File
@@ -0,0 +1,4 @@
// Generated, do not edit. sha256:ee03a242f108848e1606070a4714dcb411b1cda3ff0ab24df06aa28fabc71f72
export function isOwnWealth(account) {
return !account || account.wealthScope == null;
}
+87
View File
@@ -0,0 +1,87 @@
// Generated, do not edit. sha256:e8c40c7f89911d6db87ea8c48a3cd675bb64d66a36f01a1148bfcf8fc28f29e5
export const MEMO_MAX = 2000;
const HEX64 = /^[0-9a-f]{64}$/;
const tag = (tags, name) => (Array.isArray(tags) ? tags.find(t => Array.isArray(t) && t[0] === name)?.[1] : undefined);
function zapRequest(memo) {
let ev = memo;
if (typeof memo === 'string') {
const s = memo.trim();
if (!s.startsWith('{')) return null;
try { ev = JSON.parse(s); } catch { return null; }
}
if (!ev || typeof ev !== 'object' || Number(ev.kind) !== 9734) return null;
return ev;
}
export function zapContext(memo) {
const ev = zapRequest(memo);
if (!ev) return null;
const hex = v => (typeof v === 'string' && HEX64.test(v.toLowerCase()) ? v.toLowerCase() : null);
const message = typeof ev.content === 'string' ? ev.content.trim() : '';
return { sender: hex(ev.pubkey), recipient: hex(tag(ev.tags, 'p')), message, note: hex(tag(ev.tags, 'e')) };
}
export function compactMemo(memo) {
const ev = zapRequest(memo);
if (ev) {
const tags = (Array.isArray(ev.tags) ? ev.tags : []).filter(t => Array.isArray(t) && (t[0] === 'p' || t[0] === 'e'))
.map(t => [t[0], String(t[1] ?? '')]);
const klein = { kind: 9734, pubkey: String(ev.pubkey ?? ''), content: String(ev.content ?? '').slice(0, 1000), tags };
return JSON.stringify(klein).slice(0, MEMO_MAX);
}
if (typeof memo !== 'string') return null;
const s = memo.trim();
return s ? s.slice(0, MEMO_MAX) : null;
}
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
function polymod(values) {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
let chk = 1;
for (const v of values) {
const top = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i];
}
return chk;
}
function toWords(bytes) {
const out = [];
let acc = 0, bits = 0;
for (const b of bytes) {
acc = (acc << 8) | b;
bits += 8;
while (bits >= 5) { bits -= 5; out.push((acc >> bits) & 31); }
}
if (bits > 0) out.push((acc << (5 - bits)) & 31);
return out;
}
export function npub(hex) {
if (typeof hex !== 'string' || !HEX64.test(hex)) return null;
const bytes = hex.match(/../g).map(h => parseInt(h, 16));
const hrp = 'npub';
const data = toWords(bytes);
const expand = [...[...hrp].map(c => c.charCodeAt(0) >> 5), 0, ...[...hrp].map(c => c.charCodeAt(0) & 31)];
const mod = polymod([...expand, ...data, 0, 0, 0, 0, 0, 0]) ^ 1;
const checksum = Array.from({ length: 6 }, (_, i) => (mod >> (5 * (5 - i))) & 31);
return `${hrp}1${[...data, ...checksum].map(w => CHARSET[w]).join('')}`;
}
export function npubShort(hex) {
const n = npub(hex);
return n ? `${n.slice(0, 9)}…${n.slice(-4)}` : null;
}
export function memoNote(memo, direction) {
const z = zapContext(memo);
if (z) {
const wie = npubShort(direction === 'in' ? z.sender : z.recipient);
const kop = `zap ${direction === 'in' ? 'from' : 'to'}${wie ? ` ${wie}` : ''}`;
return z.message ? `${kop}: "${z.message}"` : kop;
}
const s = compactMemo(memo);
return s ? s.slice(0, 200) : null;
}