Most BaZi (Four Pillars) readings come back as a wall of text. You get a birth chart, a list of ten-year luck periods, and paragraphs about each one. It's hard to see the shape of it. Which decade is the high point? Is 2027 better or worse than 2026?
I've spent a lot of time looking at stock charts, and at some point the obvious idea showed up: a life laid out year by year is a time series. A time series with ups, downs and turning points is what candlestick charts were made for. So I built Life K-Lines (人生K线 in Chinese, "K-line" being the Chinese name for a candlestick chart). You enter your birth time and it draws your luck cycles as candles.
This post covers how it works under the hood: the calendar data model, how a yearly reading becomes open/high/low/close values, the chart, and how an LLM fits in without being allowed to make things up freely.
A note before we start: this is a traditional-culture and entertainment project. I'm not claiming the chart predicts anything. The engineering problems are real either way.
The stack, briefly
The frontend is React with React Router, built with Vite. Charts are drawn with Recharts. Auth runs on better-auth, and the static site is served from Netlify behind Cloudflare. AI calls go through my own backend at api.lifeklines.com, never straight from the browser to a model provider, so keys stay server-side and I can enforce quotas.
Part 1: The data model
BaZi is built on the sexagenary cycle. There are 10 heavenly stems (甲 乙 丙 丁 戊 己 庚 辛 壬 癸) and 12 earthly branches (子 丑 寅 卯 辰 巳 午 未 申 酉 戌 亥). Pair them in step and you get 60 combinations, called 干支 (gan-zhi), which cycle forever. Each one is tied to one of the five elements and to yin or yang.
A birth moment maps to four of these pairs, the "four pillars": year, month, day and hour. That's eight characters, hence "BaZi" (eight characters).
On top of the natal chart there are two timelines that matter for a chart like mine:
- Luck pillars (大运, da yun): ten-year periods, each with its own stem-branch pair. They're derived from the month pillar and run forward or backward depending on gender and the yin/yang of the birth year. The starting age is computed from the distance between the birth date and the nearest solar term, which is why one person's first luck pillar starts at 3 and another's at 8.
- Yearly pillars (流年, liu nian): simply the stem-branch pair of each calendar year. 2026 is 丙午, 2027 is 丁未, and so on.
I did not write the calendar math myself. Solar terms, the lunar calendar and the luck-pillar start date are full of edge cases. I use lunar-typescript, a TypeScript port of the well-known lunar calendar library. Here's roughly what pulling the luck pillars looks like:
// Simplified example
import { Solar } from 'lunar-typescript';
interface LuckPillar {
ganZhi: string; // e.g. "甲子"
startAge: number;
startYear: number;
endYear: number;
}
function getLuckPillars(
y: number, m: number, d: number, h: number,
gender: 'male' | 'female'
): LuckPillar[] {
const eightChar = Solar.fromYmdHms(y, m, d, h, 0, 0)
.getLunar()
.getEightChar();
// The library takes 1 for male and 0 for female; it uses this
// to decide whether the luck pillars run forward or backward.
const yun = eightChar.getYun(gender === 'male' ? 1 : 0);
return yun
.getDaYun()
.slice(1) // index 0 is the childhood period before the first pillar
.filter((p) => p.getGanZhi())
.map((p) => ({
ganZhi: p.getGanZhi(),
startAge: p.getStartAge(),
startYear: p.getStartYear(),
endYear: p.getEndYear(),
}));
}
That gives the skeleton: a sequence of ten-year blocks, and inside each block, ten yearly pillars. What it doesn't give you is a number. And a candlestick needs numbers.
Part 2: From a reading to a score
This is the step that took the most iteration.
In traditional practice, a year is "good" or "bad" for someone depending on how that year's stem and branch interact with the natal chart. The key idea is the favorable element (喜用神): the elements that bring an unbalanced chart back toward balance. When a luck pillar or yearly pillar brings those in, the period is read as supportive. When it brings the unfavorable ones, or clashes with key pillars, it's read as turbulent.
There are two broad ways to get there:
- A hand-written rule engine that adds and subtracts points for each interaction (combinations, clashes, ten-god relationships). It's deterministic, but brittle. Real readings weigh dozens of interactions against each other, and getting the weights to feel right is hard.
- Letting an LLM score each year, under tight constraints. This is what Life K-Lines does.
The LLM gets the computed pillars (the library's output, not the model's guess) and has to return a score from 0 to 100 for each year, for a total plus several dimensions: wealth, career, family and health. The prompt pins down how scoring should behave. In simplified form, a year where the favorable element arrives should land around 75–90, a year where the unfavorable element arrives should land around 40–60, and clashes between the yearly and luck pillars should produce sharper swings.
The prompt also says the luck pillar order must follow the direction and first pillar I pass in. Models like to "correct" this from their own memory of BaZi rules, and they're often wrong.
Part 3: Scores to candles
Once you have one score per year, the OHLC mapping is simple. I treat each year like a trading day:
- Open = last year's close (the first year opens at its own score)
- Close = this year's score
- High / Low = the range between open and close, stretched a little
So a green candle means the year ended better than the previous one, and a red one means it ended worse. (Mainland Chinese stock charts use the opposite convention, red for up and green for down, so pick colors for your audience.)
// Simplified example
interface YearScore {
year: number;
age: number;
ganZhi: string; // yearly pillar, e.g. "丙午"
score: number; // 0-100
}
interface Candle extends YearScore {
open: number;
high: number;
low: number;
close: number;
}
const clamp = (v: number) => Math.min(100, Math.max(0, v));
function toCandles(points: YearScore[], wick = 5): Candle[] {
let prevClose: number | null = null;
return points.map((p) => {
const open = prevClose ?? p.score;
const close = p.score;
const high = clamp(Math.max(open, close) + Math.random() * wick);
const low = clamp(Math.min(open, close) - Math.random() * wick);
prevClose = close;
return { ...p, open, high, low, close };
});
}
I want to be upfront about the wicks: they're decoration. A single yearly score has no real intra-year high and low, so the wick length is a small random offset that makes the chart read like a candlestick and not a bar chart. Open and close carry the meaning. The wicks only carry the look. I run the same function for each dimension, so you can switch the chart from "total" to "career" or "health" and get a separate series.
Rendering
Recharts doesn't ship a candlestick type, so you have to put one together. The usual approach, and the one I'd recommend, is a ComposedChart with a Bar whose value is the [low, high] range and a custom shape function. Inside the shape you get the bar's pixel coordinates. From those you draw a thin line for the wick and a rectangle for the body, scaled between open and close, colored by direction.
If you build something similar, two things matter more for readability than any styling:
- Mark the luck pillars. People think in decades with this system, so make each ten-year block visible, for example with a light background band and the pillar's characters at the top.
-
Show the reason, not just the number. Each data point already carries a short
reasonfrom the model, so put it in the tooltip next to the yearly pillar and the score. A bare "62" means nothing on its own.
Part 4: Keeping the LLM in line
The model does two jobs. It scores each year for the chart, and it writes the longer interpretation: chart structure, ten gods, favorable elements, and career, wealth, relationship and health sections.
A few things made this reliable enough to ship.
Structured output, validated on the client. The chart prompt asks for a single JSON object with a chartPoints array. Each point has age, year, daYun, ganZhi, score, a short reason, an advice field and a scores object. Before anything is drawn, the response is parsed and checked. Required text fields must be non-empty strings. Every score must be a finite number between 0 and 100. If a dimension is missing, it falls back to the total score rather than crashing the chart.
// Simplified example
type Dimensions = 'total' | 'wealth' | 'career' | 'family' | 'health';
interface ChartPoint {
age: number;
year: number;
daYun: string;
ganZhi: string;
score: number;
reason: string;
scores: Partial<Record<Dimensions, number>>;
}
const isScore = (v: unknown): v is number =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100;
function parseChartPoints(raw: string): ChartPoint[] {
// Models often wrap JSON in a ```
{% endraw %}
json fence despite instructions
const json = raw.replace(/^[\s\S]*?
{% raw %}
```json/, '').replace(/```
{% endraw %}
[\s\S]*$/, '');
const data = JSON.parse(json);
if (!Array.isArray(data.chartPoints)) {
throw new Error('chartPoints missing');
}
return data.chartPoints.filter(
(p: ChartPoint) => isScore(p.score) && typeof p.ganZhi === 'string'
);
}
{% raw %}
Facts from code, wording from the model. The pillars, luck-pillar boundaries and ages all come from the calendar library and go into the prompt as fixed input. The model never computes them. That one rule removed the most embarrassing class of bugs, like a luck pillar starting in the wrong year or a yearly pillar that doesn't match the calendar.
A wording policy appended to every system prompt. Fortune-telling content goes wrong in some predictable ways: absolute promises, fear, and pushing people to pay to "fix" bad luck. Every request gets the same block appended to its system prompt. It tells the model not to give itself authority titles, not to use absolute language like "guaranteed" or "definitely", not to promise outcomes, not to frighten anyone, and to point health, legal, financial or mental-health questions to real professionals. The block also says that if a JSON format was requested, the format wins. Without that line, the policy text sometimes "leaked" into the structure.
Streaming through a backend proxy. Requests go to my API with the session cookie. The server checks entitlements and quotas, calls the model, and streams tokens back. A full chart response is long (up to several thousand tokens), so streaming matters. Users can watch the reasoning arrive and don't stare at a spinner.
What I'd do differently
- Cache by chart, not by request. Two people born in the same two-hour window share the same pillars. A lot of the structural analysis could be reused.
- Make scores less random between runs. At temperature 0.7, regenerating a chart can move individual years by several points. That's fine for text, but less fine for a chart people compare over time. Saving charts to a profile helps, because you look at the same result again instead of regenerating it.
- Lead with the chart. A wall of text is the problem I set out to fix, so the chart should be the first thing you see, with the long interpretation underneath.
Try it
If you want to see what your own decades look like as candles, it's at lifeklines.com. Basic features are free with an account. The K-line chart itself is a VIP feature, and new accounts get a 3-day trial that includes it. The same site also has tarot, I Ching (Liu Yao) divination, an MBTI test and an AI chat advisor, all built on the same proxy and prompt setup described above.
I'm happy to go deeper on any part in the comments, especially the solar-term edge cases or the prompt structure. I'm @puexip on X if you'd rather talk there.
Top comments (0)