Add pytest harness and 55-test suite for core backend logic

Covers: billing-cycle characterization (the BE-06 convention, boundary
instants, year wraps, deferred transactions), projection engine
(recurring schedules, no-double-count, cumulative balances, overrides,
fresh start), auth (JWT roundtrip/expiry, API tokens, constant-time
creds, rate-limit window), fail-fast settings, and the BAC paste
parser. SQLite in-memory fixtures, exchange rates pinned — no network.
Also doubles as the float-behavior baseline for the Phase 2 Decimal
migration (plan 1.5). config.py moves to SettingsConfigDict to clear
the pydantic deprecation warning from test output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-09 19:46:54 -06:00
parent 996bd437a1
commit c725c1487d
9 changed files with 707 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
return self
class Config:
env_file = ".env"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

View File

@@ -0,0 +1 @@
pytest

View File

48
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
"""Shared fixtures. Env vars are set before any app import so config.Settings()
fail-fast validation passes with deterministic test values regardless of the
developer's .env."""
import os
os.environ.setdefault(
"SECRET_KEY", "test-secret-key-0123456789abcdef0123456789abcdef"
)
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
os.environ.setdefault("DATABASE_URL", "sqlite://")
import pytest
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
import app.models.models # noqa: F401 — register all tables on the metadata
@pytest.fixture()
def engine():
eng = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture()
def session(engine):
with Session(engine) as s:
yield s
@pytest.fixture(autouse=True)
def fixed_exchange_rates(monkeypatch):
"""Never hit the network: CRC passthrough, USD pinned at 500."""
from app.services import exchange_rate
monkeypatch.setattr(
exchange_rate,
"get_crc_multipliers",
lambda session: {"CRC": 1.0, "USD": 500.0},
)

154
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,154 @@
"""JWT lifecycle, API-token validation, constant-time creds, login rate limit."""
from datetime import timedelta
import pytest
from fastapi import HTTPException, Request
from jose import jwt
from app import auth as auth_mod
from app.auth import (
ALGORITHM,
LOGIN_RATE_LIMIT,
_login_attempts,
_validate_token,
check_login_rate_limit,
create_access_token,
hash_token,
verify_admin_credentials,
)
from app.config import settings
from app.models.models import APIToken
from app.timeutil import utcnow
def make_request(ip: str) -> Request:
return Request(
{
"type": "http",
"headers": [(b"x-forwarded-for", ip.encode())],
"client": ("127.0.0.1", 1234),
}
)
class TestJWT:
def test_roundtrip_and_seven_day_expiry(self):
token = create_access_token("charlie")
assert _validate_token(token) == "charlie"
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
# exp is a true UTC epoch; a naive datetime's .timestamp() would be
# interpreted as local time, so compare against an aware now.
from datetime import datetime, timezone
lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp()
assert lifetime == pytest.approx(7 * 24 * 3600, abs=60)
def test_expired_token_rejected(self, engine, monkeypatch):
# The API-token fallback path opens a DB session; point it at the
# test engine so the lookup runs (and finds nothing).
monkeypatch.setattr("app.db.engine", engine)
expired = jwt.encode(
{"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)},
settings.SECRET_KEY,
algorithm=ALGORITHM,
)
with pytest.raises(HTTPException) as exc:
_validate_token(expired)
assert exc.value.status_code == 401
def test_garbage_token_rejected(self, engine, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
with pytest.raises(HTTPException):
_validate_token("not-a-jwt")
class TestAPIToken:
def test_active_api_token_accepted(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True)
)
session.commit()
assert _validate_token("tok-123") == "api:n8n"
def test_expired_api_token_rejected(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(
name="old",
token_hash=hash_token("tok-old"),
is_active=True,
expires_at=utcnow() - timedelta(days=1),
)
)
session.commit()
with pytest.raises(HTTPException) as exc:
_validate_token("tok-old")
assert exc.value.status_code == 401
def test_revoked_api_token_rejected(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(
name="revoked",
token_hash=hash_token("tok-rev"),
is_active=False,
)
)
session.commit()
with pytest.raises(HTTPException):
_validate_token("tok-rev")
class TestCredentials:
def test_correct_credentials_pass(self):
verify_admin_credentials(
settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD
) # no exception
@pytest.mark.parametrize(
"user,pw",
[
("wrong", "test-password"),
("testadmin", "wrong"),
("", ""),
],
)
def test_wrong_credentials_rejected(self, user, pw):
with pytest.raises(HTTPException) as exc:
verify_admin_credentials(user, pw)
assert exc.value.status_code == 401
class TestLoginRateLimit:
def setup_method(self):
_login_attempts.clear()
def test_limit_allows_then_blocks(self):
req = make_request("203.0.113.1")
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(req)
with pytest.raises(HTTPException) as exc:
check_login_rate_limit(req)
assert exc.value.status_code == 429
assert "Retry-After" in exc.value.headers
def test_limit_is_per_ip(self):
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(make_request("203.0.113.2"))
# A different IP is unaffected.
check_login_rate_limit(make_request("203.0.113.3"))
def test_window_slides(self):
req = make_request("203.0.113.4")
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(req)
# Age every recorded attempt past the window; the next attempt passes.
attempts = _login_attempts["203.0.113.4"]
for i in range(len(attempts)):
attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1
check_login_rate_limit(req) # no exception

View File

@@ -0,0 +1,196 @@
"""Projection engine: recurring-item schedules, no-double-count, cumulative
balances with overrides and the 2026-03 fresh start. Also serves as the
float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5)."""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
BalanceOverride,
Category,
RecurringFrequency,
RecurringItem,
RecurringItemType,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_effective_amount,
)
def make_item(**kw) -> RecurringItem:
defaults = dict(
name="item",
amount=1000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
)
defaults.update(kw)
return RecurringItem(**defaults)
class TestEffectiveAmount:
def test_monthly_base_and_override(self):
item = make_item(amount=100.0, override_amounts={"4": 250.0})
assert get_effective_amount(item, 3, 2026) == 100.0
assert get_effective_amount(item, 4, 2026) == 250.0
def test_weekly_counts_weekday_occurrences(self):
# day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays
# (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27).
item = make_item(
amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0
)
assert get_effective_amount(item, 3, 2026) == 500.0
assert get_effective_amount(item, 4, 2026) == 400.0
def test_quarterly_active_in_quarter_end_months_only(self):
item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0)
for month in (3, 6, 9, 12):
assert get_effective_amount(item, month, 2026) == 900.0
for month in (1, 2, 4, 5, 7, 8, 10, 11):
assert get_effective_amount(item, month, 2026) is None
def test_biannual_base_and_six_months_later(self):
item = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0
)
assert get_effective_amount(item, 2, 2026) == 70.0
assert get_effective_amount(item, 8, 2026) == 70.0
assert get_effective_amount(item, 5, 2026) is None
late = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0
)
assert get_effective_amount(late, 9, 2026) == 70.0
assert get_effective_amount(late, 3, 2026) == 70.0
def test_yearly_defaults_to_december(self):
item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0)
assert get_effective_amount(item, 12, 2026) == 50.0
assert get_effective_amount(item, 6, 2026) is None
july = make_item(
frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0
)
assert get_effective_amount(july, 7, 2026) == 50.0
class TestMonthlyProjection:
def _seed(self, session: Session):
groceries = Category(name="Groceries")
other = Category(name="Other")
session.add(groceries)
session.add(other)
session.commit()
session.refresh(groceries)
session.refresh(other)
session.add(
RecurringItem(
name="Salary",
amount=500_000.0,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.add(
RecurringItem(
name="Groceries budget",
amount=100_000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
category_id=groceries.id,
)
)
session.commit()
return groceries, other
def _cc(self, date, amount, category_id=None):
return Transaction(
amount=amount,
merchant="X",
date=date,
transaction_type=TransactionType.COMPRA,
source=TransactionSource.CREDIT_CARD,
category_id=category_id,
)
def test_actuals_replace_projection_for_covered_category(self, session):
groceries, _ = self._seed(session)
# April budget ⇒ cycle [Mar 18, Apr 18)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
grocery_line = next(
i for i in data["expense_items"] if i["name"] == "Groceries budget"
)
assert grocery_line["used_actual"] is True
assert grocery_line["amount"] == pytest.approx(80_000.0)
assert grocery_line["projected_amount"] == pytest.approx(100_000.0)
assert data["projected_fixed_expenses"] == pytest.approx(80_000.0)
def test_projection_used_when_no_actuals(self, session):
self._seed(session)
data = compute_monthly_projection(session, 2026, 4)
assert data["projected_fixed_expenses"] == pytest.approx(100_000.0)
assert data["projected_income"] == pytest.approx(500_000.0)
assert data["net_balance"] == pytest.approx(400_000.0)
def test_uncovered_and_uncategorized_actuals_add_up(self, session):
groceries, other = self._seed(session)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id))
session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
assert data["uncovered_actual"] == pytest.approx(7_000.0)
assert data["gran_total_egresos"] == pytest.approx(87_000.0)
assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0)
class TestYearlyCumulative:
def _income_only(self, session: Session, amount=100.0):
session.add(
RecurringItem(
name="Income",
amount=amount,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.commit()
def test_fresh_start_zeroes_months_before_march_2026(self, session):
self._income_only(session)
months = compute_yearly_projection_with_cumulative(session, 2026)
assert months[0]["cumulative_balance"] == 0.0 # January
assert months[1]["cumulative_balance"] == 0.0 # February
assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March
assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec
def test_override_resets_cumulative_chain(self, session):
self._income_only(session)
session.add(BalanceOverride(year=2026, month=4, override_balance=500.0))
session.commit()
months = compute_yearly_projection_with_cumulative(session, 2026)
april, may = months[3], months[4]
assert april["balance_overridden"] is True
assert april["cumulative_balance"] == pytest.approx(500.0)
assert may["carryover_balance"] == pytest.approx(500.0)
assert may["cumulative_balance"] == pytest.approx(600.0)
def test_next_year_carries_december_forward(self, session):
self._income_only(session)
months_2027 = compute_yearly_projection_with_cumulative(session, 2027)
# 2026 accrues 10 months (MarDec) of 100 → Jan 2027 carries 1000.
assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0)
assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0)

View File

@@ -0,0 +1,44 @@
"""Fail-fast settings validation (SEC-01 / BE-15)."""
import pytest
from pydantic import ValidationError
from app.config import Settings
GOOD = {
"SECRET_KEY": "f" * 64,
"ADMIN_USERNAME": "charlie",
"ADMIN_PASSWORD": "a-real-password",
}
def make(**overrides) -> Settings:
return Settings(_env_file=None, **{**GOOD, **overrides})
def test_valid_settings_boot():
s = make()
assert s.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7
assert s.COOKIE_SECURE is True
assert s.cors_origins_list[0] == "https://wealth.cescalante.dev"
@pytest.mark.parametrize(
"overrides",
[
{"SECRET_KEY": "change-me-in-production"},
{"SECRET_KEY": "short"},
{"SECRET_KEY": ""},
{"ADMIN_PASSWORD": "admin"},
{"ADMIN_PASSWORD": ""},
{"ADMIN_USERNAME": ""},
],
)
def test_weak_or_missing_secrets_rejected(overrides):
with pytest.raises(ValidationError):
make(**overrides)
def test_cors_origins_parse_and_strip():
s = make(CORS_ORIGINS=" https://a.example , http://b.example ,")
assert s.cors_origins_list == ["https://a.example", "http://b.example"]

View File

@@ -0,0 +1,202 @@
"""Characterization tests for billing-cycle math (review finding BE-06).
VERDICT: BE-06 is a false positive. The system is internally consistent:
- `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M:
[M/18, (M+1)/18).
- Budget month M covers the credit-card cycle that ENDS on the 18th of M,
i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) —
exactly what the comment in compute_actuals_by_source says.
- The frontend (Budget.tsx) follows the same convention: viewing month M it
queries cycle_month = M-1.
These tests pin that convention so it can never silently shift.
"""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
Currency,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_actuals_by_source,
get_cycle_range,
get_month_range,
get_previous_cycle,
)
def cc_tx(
date: datetime,
amount: float = 1000.0,
*,
tx_type: TransactionType = TransactionType.COMPRA,
source: TransactionSource = TransactionSource.CREDIT_CARD,
currency: Currency = Currency.CRC,
deferred: bool = False,
) -> Transaction:
return Transaction(
amount=amount,
currency=currency,
merchant="TEST",
date=date,
transaction_type=tx_type,
source=source,
deferred_to_next_cycle=deferred,
)
def cc_net(session: Session, year: int, month: int) -> float:
return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"]
class TestRangeHelpers:
def test_cycle_starts_on_the_18th_of_its_label_month(self):
assert get_cycle_range(2026, 3) == (
datetime(2026, 3, 18),
datetime(2026, 4, 18),
)
def test_december_cycle_wraps_into_next_year(self):
assert get_cycle_range(2026, 12) == (
datetime(2026, 12, 18),
datetime(2027, 1, 18),
)
def test_month_range_normal_and_december(self):
assert get_month_range(2026, 4) == (
datetime(2026, 4, 1),
datetime(2026, 5, 1),
)
assert get_month_range(2026, 12) == (
datetime(2026, 12, 1),
datetime(2027, 1, 1),
)
def test_previous_cycle_january_wraps_to_prior_december(self):
assert get_previous_cycle(2026, 1) == (2025, 12)
assert get_previous_cycle(2026, 7) == (2026, 6)
@pytest.mark.parametrize("month", [0, 13, -1, 99])
def test_invalid_month_rejected(self, month):
with pytest.raises(ValueError):
get_cycle_range(2026, month)
with pytest.raises(ValueError):
get_month_range(2026, month)
class TestBudgetMonthCycleConvention:
"""Budget month M ← credit-card cycle [(M-1)/18, M/18)."""
def test_april_budget_covers_march18_to_april17(self, session):
included = [
cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle
cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle
cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle
]
excluded = [
cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle
cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle
]
for tx in included + excluded:
session.add(tx)
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(700.0)
# The boundary transactions land in the adjacent budget months instead.
assert cc_net(session, 2026, 3) == pytest.approx(7000.0)
assert cc_net(session, 2026, 5) == pytest.approx(9000.0)
def test_january_budget_reaches_into_prior_year(self, session):
session.add(cc_tx(datetime(2025, 12, 20), 500.0))
session.commit()
assert cc_net(session, 2026, 1) == pytest.approx(500.0)
def test_cash_uses_calendar_month_not_cycle(self, session):
session.add(
cc_tx(
datetime(2026, 4, 17),
300.0,
source=TransactionSource.CASH,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)
assert actuals["CASH"]["net"] == pytest.approx(300.0)
# Not in March's calendar month, despite being in March's... no —
# Apr 17 belongs to April calendar month only.
assert compute_actuals_by_source(session, 2026, 3)["CASH"][
"net"
] == pytest.approx(0.0)
class TestDeferredTransactions:
def test_deferred_moves_to_next_budget_month(self, session):
# 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3.
# Deferred, it must count in budget month 4 instead.
session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True))
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(0.0)
assert cc_net(session, 2026, 4) == pytest.approx(1500.0)
# And it does not leak into month 5.
assert cc_net(session, 2026, 5) == pytest.approx(0.0)
def test_deferred_devolucion_also_moves(self, session):
session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3
session.add(
cc_tx(
datetime(2026, 3, 10),
2000.0,
tx_type=TransactionType.DEVOLUCION,
deferred=True,
)
)
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(9000.0)
assert cc_net(session, 2026, 4) == pytest.approx(-2000.0)
class TestAmountSemantics:
def test_devolucion_subtracts_from_net(self, session):
session.add(cc_tx(datetime(2026, 4, 1), 5000.0))
session.add(
cc_tx(
datetime(2026, 4, 2),
1200.0,
tx_type=TransactionType.DEVOLUCION,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"]
assert actuals["total_compra"] == pytest.approx(5000.0)
assert actuals["total_devolucion"] == pytest.approx(1200.0)
assert actuals["net"] == pytest.approx(3800.0)
assert actuals["count"] == 2
def test_usd_converted_at_multiplier(self, session):
# conftest pins USD→CRC at 500.
session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD))
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(5000.0)
def test_income_types_excluded_from_counts(self, session):
session.add(
cc_tx(
datetime(2026, 4, 1),
999999.0,
tx_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"]
assert actuals["count"] == 0
# SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net.
assert actuals["net"] == pytest.approx(0.0)

View File

@@ -0,0 +1,60 @@
"""Characterization of the BAC paste-import line parser."""
from datetime import datetime
from app.api.v1.endpoints.import_transactions import (
make_reference_hash,
parse_bac_line,
)
from app.models.models import TransactionType
class TestParseBacLine:
def test_tab_separated_purchase(self):
line = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
parsed = parse_bac_line(line)
assert parsed is not None
assert parsed["date"] == datetime(2026, 4, 15)
assert parsed["merchant"] == "WALMART"
assert parsed["city"] == "SAN JOSE"
assert parsed["amount"] == 25000.50
assert parsed["currency"] == "CRC"
assert parsed["transaction_type"] == TransactionType.COMPRA
def test_multispace_separated_usd(self):
line = "01/05/2026 AMAZON\\SEATTLE\\US 12.99 USD"
parsed = parse_bac_line(line)
assert parsed is not None
assert parsed["amount"] == 12.99
assert parsed["currency"] == "USD"
def test_negative_amount_is_refund(self):
parsed = parse_bac_line("15/04/2026\tSTORE\\X\\CR\t-5,000.00 CRC")
assert parsed is not None
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
assert parsed["amount"] == 5000.0 # stored as absolute value
def test_pago_recibido_is_refund(self):
parsed = parse_bac_line("15/04/2026\tPAGO RECIBIDO\\\\\t100.00 CRC")
assert parsed is not None
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
def test_unparseable_lines_return_none(self):
assert parse_bac_line("") is None
assert parse_bac_line("not a transaction") is None
assert parse_bac_line("99/99/2026\tX\\Y\\Z\t1.00 CRC") is None
assert parse_bac_line("15/04/2026\tX\\Y\\Z\t1.00 GBP") is None
class TestReferenceHash:
def test_deterministic_and_merchant_normalized(self):
a = make_reference_hash(datetime(2026, 4, 15), " walmart ", 100.0, "CRC")
b = make_reference_hash(datetime(2026, 4, 15), "WALMART", 100.0, "CRC")
assert a == b
assert len(a) == 16
def test_different_inputs_differ(self):
base = make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "CRC")
assert make_reference_hash(datetime(2026, 4, 16), "X", 100.0, "CRC") != base
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.1, "CRC") != base
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "USD") != base