Skip to content

Astrology

A natal-chart engine: tropical or sidereal zodiac, ten planets plus the lunar nodes and the Ascendant/Midheaven, Whole Sign / Equal / Placidus houses, and aspects rendered into the summary. A lightweight sun-sign spread is also available for callers who have only a birthday.

from fortune_telling_core import Querent, ReadingRequest
from fortune_telling_core.traditions.astrology import NATAL_CHART, TROPICAL_ZODIAC, build_engine

engine = build_engine()
request = ReadingRequest(
    deck_id=TROPICAL_ZODIAC.id,
    spread_id=NATAL_CHART.id,
    querent=Querent(
        id="sample",
        display_name="Sample",
        attributes={
            "birth_datetime": "1990-01-01T12:00:00+00:00",
            "latitude": "51.5074",
            "longitude": "-0.1278",
            "house_system": "whole_sign",
        },
    ),
)

reading = engine.cast(request)

Sun sign

The SUN_SIGN spread is a single-placement reading that needs only the querent's zodiac sign — no birth time, latitude, or longitude — so it serves callers who know just a birthday. The sign is taken from an explicit sun_sign attribute (a Sign, a sign symbol id like astro.sign.leo, or a bare slug like leo); when that is absent, a birth_date (or birth_datetime — only the calendar date is used) is classified into a sign using the conventional Western tropical date ranges (sign_for_date / zodiac_date_range). The placement reuses the sun position id, so the same Sun-in-sign interpretation data applies as in the natal chart.

Sun-sign readings are always tropical: the conventional date ranges are a tropical convention, and a sidereal sun sign is not well-defined from a date alone. The reading touches neither the ephemeris nor birth time and place, and like the natal chart it is deterministic and replay-safe.

from fortune_telling_core.traditions.astrology import SUN_SIGN, TROPICAL_ZODIAC

reading = engine.cast(
    ReadingRequest(
        deck_id=TROPICAL_ZODIAC.id,
        spread_id=SUN_SIGN.id,
        querent=Querent(
            id="sample",
            display_name="Sample",
            attributes={"birth_date": "1990-04-15"},  # or {"sun_sign": "aries"}
        ),
    )
)
# reading.summary == "Sun sign Aries (fire, cardinal), Mar 21 – Apr 19."

Transits

Set the request's as_of moment to add a transit report: the engine computes the transiting bodies for that instant and appends every transit-to-natal aspect (transiting planets against natal planets and angles) to the summary, under a Transits as of … heading. Without as_of the reading is the pure, timeless natal chart. The transit longitudes are stored on the draw, so replays are ephemeris-free like the natal chart. The transiting set drops the South Node (the mirror of the North Node) to avoid doubled lines; aspect orbs are the same defaults used for natal aspects.

Structured aspects

Aspects are also exposed as structured data, not just summary prose, so an interpretation layer can localize them without recomputing any astronomy. Each aspect is an entry in Reading.draw.extras — a Selection with symbol_id = "astro.aspect.<type>" (type ∈ conjunction / opposition / trine / square / sextile) and modifiers first, second (body ids), orb, and kind (natal or transit; for a transit, first is the transiting body and second the natal body). extras are draw selections not bound to a spread position, so they round-trip through serde and replay like everything else. The freeform summary is unchanged and always agrees with the structured set.

from datetime import UTC, datetime

reading = engine.cast(
    ReadingRequest(
        deck_id=TROPICAL_ZODIAC.id,
        spread_id=NATAL_CHART.id,
        querent=request.querent,
        as_of=datetime(2026, 6, 16, tzinfo=UTC),  # transits for this moment
    )
)

astrology

NATAL_CHART module-attribute

NATAL_CHART = Spread(
    id="astro.spread.natal.v1",
    name="Natal Chart",
    positions=tuple(
        (
            Position(
                id=position_id,
                name=POSITION_NAMES[position_id],
                description=f"Natal placement for {POSITION_NAMES[position_id]}.",
            )
        )
        for position_id in NATAL_POSITIONS
    ),
)

SUN_SIGN module-attribute

SUN_SIGN = Spread(
    id="astro.spread.sun_sign.v1",
    name="Sun Sign",
    positions=(
        Position(
            id=Body.SUN.value,
            name="Sun Sign",
            description="Zodiac sun sign for the birth date.",
        ),
    ),
)

SIDEREAL_ZODIAC module-attribute

SIDEREAL_ZODIAC = Deck(
    id="astro.zodiac.sidereal.v1", symbols=_symbols()
)

TROPICAL_ZODIAC module-attribute

TROPICAL_ZODIAC = Deck(
    id="astro.zodiac.tropical.v1", symbols=_symbols()
)

BuiltinEphemeris

Pure-Python deterministic ephemeris shipped with the package.

The built-in backend keeps runtime dependencies empty and is suitable for deterministic tests and general symbolic work. It is not a replacement for a high-precision professional astronomy backend.

Example
from fortune_telling_core.astronomy import Body, BuiltinEphemeris

ephemeris = BuiltinEphemeris()
sun = ephemeris.position(Body.SUN, 2451545.0)

id class-attribute instance-attribute

id = 'astro.ephemeris.builtin'

version class-attribute instance-attribute

version = '0.2.1'

supported_bodies

supported_bodies() -> frozenset[Body]

Return all bodies accepted by the built-in ephemeris.

Source code in src/fortune_telling_core/astronomy/ephemeris/builtin.py
def supported_bodies(self) -> frozenset[Body]:
    """Return all bodies accepted by the built-in ephemeris."""

    return frozenset(Body)

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return an apparent geocentric ecliptic position.

Parameters:

Name Type Description Default
body Body

Body to compute.

required
jd_tt float

Julian day on the Terrestrial Time scale.

required

Returns:

Type Description
EclipticPosition

Ecliptic longitude, approximate longitude speed, and Moon latitude.

Raises:

Type Description
EphemerisError

If body is not supported.

Source code in src/fortune_telling_core/astronomy/ephemeris/builtin.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return an apparent geocentric ecliptic position.

    Args:
        body: Body to compute.
        jd_tt: Julian day on the Terrestrial Time scale.

    Returns:
        Ecliptic longitude, approximate longitude speed, and Moon latitude.

    Raises:
        EphemerisError: If ``body`` is not supported.
    """

    if body == Body.SOUTH_NODE:
        north = self.position(Body.NORTH_NODE, jd_tt)
        return EclipticPosition(north.longitude + 180.0, north.speed)
    longitude = _longitude(body, jd_tt)
    previous_lon = _longitude(body, jd_tt - 0.5)
    next_lon = _longitude(body, jd_tt + 0.5)
    speed = _signed_delta(next_lon, previous_lon)
    latitude = _latitude(body, jd_tt)
    return EclipticPosition(longitude, speed, latitude)

FixedEphemeris

FixedEphemeris(positions: Mapping[Body, EclipticPosition])

Deterministic ephemeris backed by a fixed position mapping.

This backend is intended for tests, examples, and replay fixtures where the caller wants exact symbolic placements without astronomical calculation.

Parameters:

Name Type Description Default
positions Mapping[Body, EclipticPosition]

Mapping from body identifiers to ecliptic positions. If a north node is supplied without a south node, the south node is derived as the opposite longitude.

required
Example
from fortune_telling_core.astronomy import Body, EclipticPosition, FixedEphemeris

ephemeris = FixedEphemeris({Body.SUN: EclipticPosition(10.0)})
assert ephemeris.position(Body.SUN, 0.0).longitude == 10.0
Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def __init__(self, positions: Mapping[Body, EclipticPosition]) -> None:
    self._positions = dict(positions)
    if Body.NORTH_NODE in self._positions and Body.SOUTH_NODE not in self._positions:
        north = self._positions[Body.NORTH_NODE]
        self._positions[Body.SOUTH_NODE] = EclipticPosition(
            north.longitude + 180.0, north.speed
        )

id class-attribute instance-attribute

id = 'astro.ephemeris.fixed'

version class-attribute instance-attribute

version = '0.1.0'

supported_bodies

supported_bodies() -> frozenset[Body]

Return the bodies present in the fixed mapping.

Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def supported_bodies(self) -> frozenset[Body]:
    """Return the bodies present in the fixed mapping."""

    return frozenset(self._positions)

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return the fixed position for a body.

Parameters:

Name Type Description Default
body Body

Body to look up.

required
jd_tt float

Ignored Julian day, accepted for protocol compatibility.

required

Returns:

Type Description
EclipticPosition

The configured ecliptic position.

Raises:

Type Description
EphemerisError

If no position exists for body.

Source code in src/fortune_telling_core/astronomy/ephemeris/fixed.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return the fixed position for a body.

    Args:
        body: Body to look up.
        jd_tt: Ignored Julian day, accepted for protocol compatibility.

    Returns:
        The configured ecliptic position.

    Raises:
        EphemerisError: If no position exists for ``body``.
    """

    del jd_tt
    value = self._positions.get(body)
    if value is None:
        raise EphemerisError(f"fixed ephemeris has no position for {body}")
    return value

Ephemeris

Bases: Protocol

Protocol for injectable astronomy backends.

Implementations provide apparent geocentric ecliptic positions in degrees. Latitude may be None when a backend or body does not compute it. The fortune-telling traditions depend only on this protocol, so applications can bring their own precision and licensing choices.

Attributes:

Name Type Description
id str

Stable backend identifier recorded in reading provenance.

version str

Backend version recorded in reading provenance.

id instance-attribute

id: str

version instance-attribute

version: str

position

position(body: Body, jd_tt: float) -> EclipticPosition

Return an apparent geocentric ecliptic position.

Parameters:

Name Type Description Default
body Body

Body to compute.

required
jd_tt float

Julian day on the Terrestrial Time scale.

required

Returns:

Type Description
EclipticPosition

Ecliptic position for the requested body.

Raises:

Type Description
EphemerisError

If the backend cannot compute the requested body.

Source code in src/fortune_telling_core/astronomy/ephemeris/protocol.py
def position(self, body: Body, jd_tt: float) -> EclipticPosition:
    """Return an apparent geocentric ecliptic position.

    Args:
        body: Body to compute.
        jd_tt: Julian day on the Terrestrial Time scale.

    Returns:
        Ecliptic position for the requested body.

    Raises:
        EphemerisError: If the backend cannot compute the requested body.
    """

supported_bodies

supported_bodies() -> frozenset[Body]

Return the bodies supported by this ephemeris.

Source code in src/fortune_telling_core/astronomy/ephemeris/protocol.py
def supported_bodies(self) -> frozenset[Body]:
    """Return the bodies supported by this ephemeris."""

AstrologyEngine

AstrologyEngine(ephemeris: Ephemeris | None = None)

Bases: AbstractEngine

Natal astrology engine using an injectable ephemeris.

The engine casts deterministic natal charts. It ignores caller-supplied randomness and records the ephemeris, house system, and zodiac mode in reading provenance.

The engine also serves the lightweight :data:SUN_SIGN spread, which needs only the querent's zodiac sign (from an explicit sun_sign or a birth date) and uses neither the ephemeris nor birth time and location. Sun-sign readings are always tropical.

When the request carries as_of, the engine additionally computes the transiting bodies for that moment and appends transit-to-natal aspects to the summary. The natal chart is timeless, so without as_of the reading is the pure natal chart. Transit positions are stored on the draw, so a replay reproduces them without the ephemeris.

Parameters:

Name Type Description Default
ephemeris Ephemeris | None

Optional backend implementing the shared ephemeris protocol. When omitted, BuiltinEphemeris is used.

None
Source code in src/fortune_telling_core/traditions/astrology/engine.py
def __init__(self, ephemeris: Ephemeris | None = None) -> None:
    self.ephemeris = ephemeris or BuiltinEphemeris()

id class-attribute instance-attribute

id = 'astro.engine'

version class-attribute instance-attribute

version = '0.1.0'

ephemeris instance-attribute

ephemeris = ephemeris or BuiltinEphemeris()

deck

deck(request: ReadingRequest) -> Deck

Return the zodiac deck implied by the request.

Parameters:

Name Type Description Default
request ReadingRequest

Reading request containing birth data and optional zodiac configuration in options or querent attributes.

required

Returns:

Type Description
Deck

TROPICAL_ZODIAC for the sun-sign spread, SIDEREAL_ZODIAC

Deck

when a natal request asks for sidereal astrology, otherwise

Deck

TROPICAL_ZODIAC.

Raises:

Type Description
ValidationError

If required birth data or configuration is invalid.

Source code in src/fortune_telling_core/traditions/astrology/engine.py
def deck(self, request: ReadingRequest) -> Deck:
    """Return the zodiac deck implied by the request.

    Args:
        request: Reading request containing birth data and optional
            ``zodiac`` configuration in options or querent attributes.

    Returns:
        ``TROPICAL_ZODIAC`` for the sun-sign spread, ``SIDEREAL_ZODIAC``
        when a natal request asks for sidereal astrology, otherwise
        ``TROPICAL_ZODIAC``.

    Raises:
        ValidationError: If required birth data or configuration is
            invalid.
    """

    if request.spread_id == SUN_SIGN.id:
        return TROPICAL_ZODIAC
    birth = parse_birth_data(request)
    return SIDEREAL_ZODIAC if birth.config.zodiac == ZodiacMode.SIDEREAL else TROPICAL_ZODIAC

spread

spread(request: ReadingRequest) -> Spread

Return the spread implied by the request.

Parameters:

Name Type Description Default
request ReadingRequest

Reading request whose spread_id must match NATAL_CHART.id or SUN_SIGN.id.

required

Returns:

Type Description
Spread

The natal chart spread or the sun-sign spread.

Raises:

Type Description
ValidationError

If the requested spread is unsupported.

Source code in src/fortune_telling_core/traditions/astrology/engine.py
def spread(self, request: ReadingRequest) -> Spread:
    """Return the spread implied by the request.

    Args:
        request: Reading request whose ``spread_id`` must match
            ``NATAL_CHART.id`` or ``SUN_SIGN.id``.

    Returns:
        The natal chart spread or the sun-sign spread.

    Raises:
        ValidationError: If the requested spread is unsupported.
    """

    if request.spread_id == SUN_SIGN.id:
        return SUN_SIGN
    if request.spread_id != NATAL_CHART.id:
        raise ValidationError(f"unsupported astrology spread: {request.spread_id}")
    return NATAL_CHART

draw

draw(request: ReadingRequest, rng: Rng) -> Draw

Cast the natal chart as a deterministic draw.

Parameters:

Name Type Description Default
request ReadingRequest

Reading request with birth_datetime, latitude, and longitude in options or querent attributes.

required
rng Rng

Ignored. The argument is present for Engine compatibility.

required

Returns:

Type Description
Draw

A draw whose selections place bodies and angles in zodiac signs.

Raises:

Type Description
ValidationError

If request birth data or chart configuration is invalid.

EphemerisError

If the configured ephemeris cannot compute a required body.

Source code in src/fortune_telling_core/traditions/astrology/engine.py
def draw(self, request: ReadingRequest, rng: Rng) -> Draw:
    """Cast the natal chart as a deterministic draw.

    Args:
        request: Reading request with ``birth_datetime``, ``latitude``,
            and ``longitude`` in options or querent attributes.
        rng: Ignored. The argument is present for ``Engine``
            compatibility.

    Returns:
        A draw whose selections place bodies and angles in zodiac signs.

    Raises:
        ValidationError: If request birth data or chart configuration is
            invalid.
        EphemerisError: If the configured ephemeris cannot compute a
            required body.
    """

    del rng
    if request.spread_id == SUN_SIGN.id:
        return cast_sun_sign_draw(request)
    birth = parse_birth_data(request)
    return cast_draw(birth, self.ephemeris, transit_at=request.as_of)

cast

cast(request: ReadingRequest) -> Reading

Cast a natal chart without a caller RNG.

Parameters:

Name Type Description Default
request ReadingRequest

Reading request containing birth data and chart options.

required

Returns:

Type Description
Reading

A reading with body placements and aspect summary.

Raises:

Type Description
ValidationError

If request birth data or chart configuration is invalid.

EphemerisError

If the configured ephemeris cannot compute a required body.

Source code in src/fortune_telling_core/traditions/astrology/engine.py
def cast(self, request: ReadingRequest) -> Reading:
    """Cast a natal chart without a caller RNG.

    Args:
        request: Reading request containing birth data and chart options.

    Returns:
        A reading with body placements and aspect summary.

    Raises:
        ValidationError: If request birth data or chart configuration is
            invalid.
        EphemerisError: If the configured ephemeris cannot compute a
            required body.
    """

    draw = self.draw(request, _NULL_RNG)
    return self._interpret(request, draw, rng=None)

Sign

Bases: StrEnum

The twelve zodiac signs in canonical Aries-to-Pisces order.

Members are the lowercase slugs used throughout the package. This enum is the single source of truth for the set of signs and their order; sign decks, longitude-to-sign lookups and date ranges all derive from it.

ARIES class-attribute instance-attribute

ARIES = 'aries'

TAURUS class-attribute instance-attribute

TAURUS = 'taurus'

GEMINI class-attribute instance-attribute

GEMINI = 'gemini'

CANCER class-attribute instance-attribute

CANCER = 'cancer'

LEO class-attribute instance-attribute

LEO = 'leo'

VIRGO class-attribute instance-attribute

VIRGO = 'virgo'

LIBRA class-attribute instance-attribute

LIBRA = 'libra'

SCORPIO class-attribute instance-attribute

SCORPIO = 'scorpio'

SAGITTARIUS class-attribute instance-attribute

SAGITTARIUS = 'sagittarius'

CAPRICORN class-attribute instance-attribute

CAPRICORN = 'capricorn'

AQUARIUS class-attribute instance-attribute

AQUARIUS = 'aquarius'

PISCES class-attribute instance-attribute

PISCES = 'pisces'

ordinal property

ordinal: int

Zero-based position in the zodiac (Aries is 0, Pisces is 11).

symbol_id property

symbol_id: str

Deck symbol id for this sign, e.g. astro.sign.aries.

display_name property

display_name: str

Capitalized English display name, e.g. Aries.

sign_for_date

sign_for_date(value: date) -> str

Return the sign symbol id whose conventional range contains a date.

Classification compares on (month, day) against the same Western tropical boundaries as :func:zodiac_date_range, so it is correct in both common and leap years (including 29 February). Only the month and day of value are consulted; the year is otherwise ignored.

These are conventional boundaries, not an astronomically exact ingress: for a precise sun sign on a given instant, cast a reading with the astrology engine instead.

Parameters:

Name Type Description Default
value date

The calendar date to classify.

required

Returns:

Type Description
str

The matching sign symbol id, e.g. "astro.sign.aries".

Source code in src/fortune_telling_core/traditions/astrology/dates.py
def sign_for_date(value: date) -> str:
    """Return the sign symbol id whose conventional range contains a date.

    Classification compares on ``(month, day)`` against the same Western
    tropical boundaries as :func:`zodiac_date_range`, so it is correct in both
    common and leap years (including 29 February). Only the month and day of
    ``value`` are consulted; the year is otherwise ignored.

    These are conventional boundaries, not an astronomically exact ingress: for
    a precise sun sign on a given instant, cast a reading with the astrology
    engine instead.

    Args:
        value: The calendar date to classify.

    Returns:
        The matching sign symbol id, e.g. ``"astro.sign.aries"``.
    """
    month_day = (value.month, value.day)
    for sign, (start, end) in _DATE_RANGES.items():
        if start <= end:
            within = start <= month_day <= end
        else:  # range wraps across the new year (Capricorn, Pisces)
            within = month_day >= start or month_day <= end
        if within:
            return sign.symbol_id
    raise ValidationError(f"no zodiac sign for date: {value!r}")  # pragma: no cover

zodiac_date_range

zodiac_date_range(
    sign: str | Sign | Symbol,
) -> tuple[MonthDay, MonthDay]

Return the conventional sun-sign date range for a zodiac sign.

The range uses the common Western tropical convention and is expressed as year-independent (month, day) boundaries, both inclusive. Capricorn and Pisces wrap across the new year, so their start boundary falls in a later month than their end boundary.

Parameters:

Name Type Description Default
sign str | Sign | Symbol

A zodiac sign as a :class:Sign, a Symbol, a symbol id (e.g. "astro.sign.aries") or a bare slug (e.g. "aries").

required

Returns:

Type Description
tuple[MonthDay, MonthDay]

A (start, end) tuple of inclusive (month, day) boundaries.

Raises:

Type Description
ValidationError

If the sign is not a recognised zodiac sign.

Source code in src/fortune_telling_core/traditions/astrology/dates.py
def zodiac_date_range(sign: str | Sign | Symbol) -> tuple[MonthDay, MonthDay]:
    """Return the conventional sun-sign date range for a zodiac sign.

    The range uses the common Western tropical convention and is expressed as
    year-independent ``(month, day)`` boundaries, both inclusive. Capricorn and
    Pisces wrap across the new year, so their start boundary falls in a later
    month than their end boundary.

    Args:
        sign: A zodiac sign as a :class:`Sign`, a Symbol, a symbol id (e.g.
            ``"astro.sign.aries"``) or a bare slug (e.g. ``"aries"``).

    Returns:
        A ``(start, end)`` tuple of inclusive ``(month, day)`` boundaries.

    Raises:
        ValidationError: If the sign is not a recognised zodiac sign.
    """
    return _DATE_RANGES[_coerce_sign(sign)]

build_engine

build_engine(
    ephemeris: Ephemeris | None = None,
) -> AstrologyEngine

Create a natal astrology engine.

Parameters:

Name Type Description Default
ephemeris Ephemeris | None

Optional ephemeris backend. Defaults to the built-in pure-Python backend.

None

Returns:

Type Description
AstrologyEngine

A new AstrologyEngine instance.

Source code in src/fortune_telling_core/traditions/astrology/engine.py
def build_engine(ephemeris: Ephemeris | None = None) -> AstrologyEngine:
    """Create a natal astrology engine.

    Args:
        ephemeris: Optional ephemeris backend. Defaults to the built-in
            pure-Python backend.

    Returns:
        A new ``AstrologyEngine`` instance.
    """

    return AstrologyEngine(ephemeris=ephemeris)