Skip to content
Lucian Daniliuc

Work

Kingfisher: a trading bot with the boring parts left in

Lucian Daniliuc 16 min read

Kingfisher trading bot screenshot, showing how it classifies incoming articles

Search "I built a trading bot" and you will find a genre. Somebody wires an LLM to a broker API over a weekend, posts a screenshot of a green equity curve, and explains that the future belongs to whoever ships fastest. The screenshot is real. The account is usually paper. The curve is usually three days long.

Nobody ever posts the fourth day.

As of writing this post, the last such claim was from 2 days ago:

I have been running one since July. It is called Kingfisher. This is the version with the boring parts left in, and the boring parts are most of it.

Two ground rules before we start. It trades no real money and never has. And I am not going to show you performance numbers today, for a reason I will get to at the end, which I think is more interesting than the numbers.

Part one: the day that killed my first idea

On 6 July 2026, Broadcom announced a deal with Apple. A friend's Telegram channel passed it along at 13:20 UTC. By then AVGO had been repricing for over an hour.

I had a position. I lost money on it. My assumption was the obvious one: I was late, so the fix is to be faster. Before writing any code, I reconstructed the day minute by minute from the news API's own article timestamps and AVGO's minute bars.

Time (UTC)EventAVGO vs previous close
12:00 to 12:01Wire print, repriced inside a single one minute bar+2.4% to +5.5%
12:16First article appears on the news aggregator+4.5%
12:26Worst case detection for a ten minute poller+4.5%
13:20The Telegram tip arrives
20:00Close+3.8%
The same session, drawn from the same file the table came from: `tests/fixtures/avgo-2026-07-06/`, four JSON files committed alongside the code
The same session, drawn from the same file the table came from: tests/fixtures/avgo-2026-07-06/, four JSON files committed alongside the code. AVGO's entire repricing happens inside the shaded lag. AMD, which is not in the announcement at all, drifts upward for the rest of the afternoon.

That table killed the idea I started with.

The aggregator runs about fifteen minutes behind the wire. The stock repriced in about one minute. The lag alone is fifteen times the entire repricing window. No amount of clever engineering on top of that data source wins that race. It was over before I opened an editor.

Then the second surprise. Being late cost almost nothing. Buying at detection and holding to the close returned -0.6%. Perfect, zero latency knowledge would have returned +0.1%. The gap between "instant" and "sixteen minutes late" was seven tenths of one percent.

My loss came from the trade structure. I had bought a 4% out of the money same day option that was never in the money from any entry on that chart. Latency was not my problem. I was.

And then the thing I did not expect. AMD was not in the announcement at all. It was already up 3.9% by detection time, and it still returned +2.5% from detection to close. AVGO, the actual subject, returned -0.6%.

The primary reprices in a minute. The ring around it drifts for hours.

That is the whole thesis. Not speed. Drift.

Part two: the spec came before the code

I wrote the design document first. It is dated 11 July 2026 and runs to about 3,200 lines.

I know how that sounds. But the section that matters is called "Evidence base (why the design looks like this)", and it contains the arithmetic above plus four conclusions. The first one is that we do not compete on speed and here is the proof we would lose.

Every decision downstream falls out of that paragraph. Poll every ten minutes, not every ten seconds, because chasing a fifteen minute lag with a faster poll is theatre. Trade stock, not options, because paper option fills are fiction and stock isolates the question I care about. Cover fifteen tickers, the Magnificent Seven plus a supply chain ring of AVGO, AMD, QCOM, MU, TSM, ASML, SMCI and PLTR, because the ring is where the drift lives.

docs/superpowers/specs/2026-07-11-kingfisher-design.md
3,180 lines, 36,200 words, written before any engine code

  Purpose                                             9 lines
  Evidence base (why the design looks like this)     29 lines
  Decisions already made                             13 lines
  Architecture                                     2234 lines
  Data model (SQLite v1, migrations from day one)    89 lines
  Error handling                                    256 lines
  Testing                                            27 lines
  Ops and config                                    498 lines
  Non-goals / later (deliberately out of v1)         20 lines

the design document, before any engine code

There is also a section called "Non-goals", listing what I deliberately did not build and the condition that would change my mind. Real money is in there, gated on four to eight weeks of paper results showing an edge. Faster news feeds are in there, with a four rung ladder from free press wire RSS up to paid real time feeds, and one sentence attached: adopt only when the data proves latency is the binding constraint.

It has not proved that. So I have not built them. A specification that tells you what not to do earns its length.

Part three: what actually runs

Every ten minutes during market hours, a poller pulls news for the fifteen tickers. Volume is around 2,000 to 2,600 articles a day, which is roughly twenty times what I planned for.

Each article goes to a classifier, an LLM call that decides whether this is material and which tickers it touches. Then a second stage issues a verdict per ticker: how much of the move has already happened. FRESH, REPRICING, or PRICED.

The verdict is the piece I would keep if I had to throw away everything else. Most of what a system like this can honestly tell you is not "buy". It is "you are already late, and here is exactly how late."

Then four mechanical strategies evaluate every material alert. A strategy only enters when its own ticker's verdict is FRESH or REPRICING. A PRICED ticker produces a journal entry explaining the skip instead of a trade.

Every material alert reaches all four strategies
Every material alert reaches all four strategies. A PRICED verdict produces a journal row explaining the skip, which is most of the volume.

S1, momentum. Trade the primary ticker at detection, exit at the same day close. The obvious one. The one most people build and stop at.

S2, counterparty. Trade the other company in the story. Broadcom announces a deal with Apple, S2 looks at Apple. The direction is inferred by the LLM, not read off the article, which makes it weaker evidence and it gets gated harder as a result.

S3, sector peer. Trade whichever universe ticker the classifier flags as most exposed to the same theme. AMD on a custom silicon story. This is the strategy that exists because of the AMD observation above. It is the drift thesis, wearing a number.

S4, overnight drift. Enter the primary ticker at the same day close and exit at the next day's close. A different window entirely, which is why several rules that apply to S1 through S3 deliberately do not apply to it.

Part four: the rules are earned, not invented

Here is where a normal build-in-public post would list features. Instead, look at how a single rule got added, because the process is the actual product.

S2 and S3 infer direction with an LLM, so in July they got a gate: skip when the inferred side fights a same day move of 1% or more against it. If the model says sell and the ticker is already up 1.5% on the day, that is a trade fighting the tape.

Two weeks later the coach proposed extending the same gate to S1. That should be an easy yes. Consistency, right?

It was not an easy yes, because S1's direction is not inferred. It is the article's own read on its own primary ticker, the strongest signal in the system. So the change was backtested first, over 188 S1 entries. The result: at that threshold the gate blocks 14 winners against 13 losers.

Direction neutral. One extra winner blocked than losers.

The change shipped anyway, and the reason is written down: it is a deliberate tail risk trade, buying protection against the rare large loss, and explicitly not a restoration of consistency. If it had been argued on consistency it would have been argued on aesthetics.

Another gate, added mid August: S1 through S3 skip an entry when the move is already absorbed in the trade's own direction. The evidence attached is a specific window, three sessions, where the trades this gate would have blocked totalled a loss with zero winners among them.

Every one of these has a number next to it. The house rule in the codebase is blunt about it: do not write "never" or "bounded by" in a comment unless the code enforces it, because overclaiming prose is the most recurring review finding in the project.

Part five: the three loops that make it self improving

This is the part I actually care about, and it is why the project has survived contact with reality.

There are three review loops running at three different time horizons, and they are deliberately not allowed to do each other's jobs.

The retro looks at one trade. After a position closes, a reflection gets written about that specific decision.

The coach looks at one session. Once per trading day, after the close, a frontier model receives the entire journal. Not a summary: the funnel counts, every alert, every decision including every skip, the trades, the retros, the mechanical counterfactuals computed from quotes for the trades that were skipped, the current threshold constants, and its own last seven reports.

It also receives a digest of the classification journal, which is the layer above alerts. Around 1,500 to 2,500 rows a day is far too much for a prompt, so it gets aggregate counts, a materiality histogram, and two capped tails with the uncapped totals stated alongside: the stories that passed the materiality bar but were dropped as duplicates, and the near misses that fell just below the threshold. Those two tails exist so the coach can audit the gates that silently drop stories before an alert ever exists.

Then the coach has to make a recommendation, and the recommendation has rules.

It must name exactly which component it targets: signal, freshness, exit, or cost. It must state a falsifiable expected effect, with a metric, a direction, and a rough size. And before committing, it has to check its own last seven reports and say out loud if it is reversing or re-touching a threshold it already changed, and argue why this is new evidence rather than oscillation.

That last rule is there because an LLM handed a daily performance readout will happily walk a threshold up on Tuesday and back down on Thursday forever.

The weekly reviewer looks across weeks. Monday morning, and it is the only agent permitted to read edge across weeks. One session is a coin flip, and the project has the receipts on that: the backtest behind the S1 gate showed a single day read inverting over eleven sessions.

It computes three mechanical gauges. Prediction skill, meaning detection to close over every journaled alert, hit rate and available move. Transit leak, meaning detection to entry, which is how much of the move is lost getting the order filled. And capture, meaning entry to exit, which is what the system actually kept.

The identity is available = transit + capture, and it holds exactly in dollars, never in percent, because each percent leg has its own denominator. The implementation rounds so the rendered sums reconcile by construction rather than approximately.

And then the line in the specification that I would put on the wall:

it reads, never targets. The north star is to determine honestly whether the system has predictive edge and where the pipeline leaks it. A calibrated "there is no signal" is a successful outcome.

A weekly reviewer that is scored on making the number go up will make the number go up. This one is scored on being right.

Part six: how a suggestion becomes code

The coach cannot change anything. It writes a report.

When a recommendation is worth acting on, it goes through a fixed pipeline: an issue, then a plan comment on that issue, then a branch named for the day and the change, then an adversarial verification pass by a different model whose job is to attack the plan, then an independent code review, then a pull request, then a final automated review.

Merge and deploy stay manual, done by me, pre-market.

Every merged pull request that changes engine behaviour gets one line in a changelog, and the grammar of that line is enforced. It names the origin, the headline, and an expect: clause with a component and a falsifiable metric. Here is a real one:

S1 to S3 skip a repricing entry already 1.5% in its own direction. Expect: freshness, in-direction 1.5 to 2.0 entries, roughly two per session, drop to zero.

Written at pull request time. Before anyone knows whether it worked. The weekly process grades it later, and the process that grades it is not the process that wrote it.

The coach writes a report and stops
The coach writes a report and stops. Everything from the issue rightward is a separate process, and the merge is mine, by hand, pre-market.

Part seven: the twenty seven entries I am not allowed to count

When I introduced that rule, the changelog already had twenty seven entries. I backfilled their expect: clauses so the file was consistent.

Then I wrote a note into the changelog, permanently, saying that all twenty seven are backfilled and must never be graded as predictions, because a clause written after the outcome is not a forecast. It is a memory. The note names the exact entry where the honest record begins.

That note costs me nothing except the ability to quietly claim twenty seven successful predictions I never made.

Entry grammar (one entry per line, newest first, dated by the
cycle/authoring day - merge and deploy may land later):

  - DATE [#PR] <origin>: <headline>
      - expect: <component>, <metric + direction>

where origin is `coach`, `owner` or `manual`, and component is
exactly one of:

  signal      which trades we take and on which side
  freshness   whether the information is still actionable at fill
  exit        closing a position correctly and on time
  cost        money or attention spent to run the system
  risk        duplicate, opposed or unbounded positions

The `expect:` clause is a falsifiable prediction and MUST be
written at PR time, before the outcome is known: it is what the
weekly prediction scoring grades, and a clause written later is
neither.

2026-07-28 (#92): the `expect:` grammar starts here. Every clause
already in this file when #92 landed - all 27 - was backfilled,
written after the change rather than at its PR. Use those for
component attribution only; never grade them as predictions. The
first genuine forecast is the next engine PR's entry.

CHANGELOG.md, the rules at the top of the file

There is a companion story. On a bad Friday in July, the obvious read was that the morning's deployment caused the damage. It was the only thing that had changed. The post mortem found the opposite: the one new change live that day had saved money, and the damage came from somewhere else entirely.

Hindsight attribution is confident, and it is wrong, and the only defence is writing down what you expect before you find out.

Part eight: edge bps, and what it does not include

For the first few weeks I watched profit and loss, because that is the number everybody watches. It felt like the score. Green day, system is working. Red day, something to fix.

It is a bad score, and it took me a while to see why.

Profit and loss tells you what happened. It does not tell you whether the thing that happened was any good, because it silently mixes two different questions: did the system make a good decision, and how much money did it happen to have riding on it. A large position that drifted slightly the right way outranks a small position that was dead right. Watch that number long enough and you start optimising for size.

What I actually wanted was a rate. How much did each dollar of capital earn while it was exposed.

The naive version of that averages each trade's own basis points, and it is also wrong. One tiny position with a spectacular percentage swings the mean far away from what your money actually did. I had the numbers in front of me in late July showing exactly that distortion.

So edge bps is notional weighted: total profit and loss divided by total absolute notional, times ten thousand. Sum over sum. It answers "what did the capital earn", not "what did the average trade do", and it does not care how big the positions were.

That shift, from a total to a rate, changed what I looked at every day. It is the single most useful thing I did to my own judgement in this project, and it cost one SQL expression.

Two details that matter more than the formula.

The rolling seven day figure on the status page is one sum over sum query across the trailing window. It is never a re-average of the daily figures, because an average of ratios is not the ratio you want. And a day with no closed trades renders blank, not zero, because zero is a claim and blank is the truth.

Now the part I want on the record.

Edge bps is gross. There are no commissions or fees anywhere in the schema.

Not modelled, not subtracted, not estimated. The trades table carries a profit and loss figure that has never met a broker fee. For a paper account testing whether a signal exists at all, that is a defensible simplification, because the question is whether there is anything there before you start paying to harvest it.

But it means every number this system reports is an upper bound. A strategy that is marginally positive gross may be negative net, and same day round trips in and out are exactly the kind of trading where fees eat thin edges alive. Adding a cost model is on the list. It is not done. Until it is, treat every figure as the optimistic case, and I would rather you hear that from me than work it out yourself.

Part nine: what is running right now

Nine days ago I changed something significant in how the system decides. I am not going to describe the change here, and that is deliberate.

Before deploying it I wrote a pre-registration: what the change is, what I predict it does, how it is measured, which conditions void the result, and the exact date I am allowed to look. That document is longer than the original design specification.

The grading window opened today. It closes around 25 September. Until then I am not permitted to grade it early, and neither is the assistant that helps me run this. The distinction that matters is not whether a number is visible, since the whole dashboard is public. It is that an interim read must not change what I ship, and the reliable way to guarantee that is to not go looking for one. Six environment variables are named as voiding the result if any of them changes, and they have no diff to catch them, so they get checked by hand. Every deployment to the engine during the window runs a confound check first and gets a disclosure comment attached to the tracking issues, whether the check finds anything or not, because a clean result is not a reason to skip the disclosure.

$ deploy/confound_check.sh 449a64f4 origin/master

confound check: 449a64f4 -> origin/master (9 changed paths)

env vars to check by hand (no git diff; a change voids):
  NOTIONAL_PER_TRADE_USD CLASSIFY_MODEL CLASSIFY_REASONING
  CLASSIFY_PROVIDER CLASSIFY_ALLOW_FALLBACKS MATERIALITY_THRESHOLD

clear - no core-list file touched.
Deploy allowed; post a one-line disclosure + confound assessment on the phase issue.

deploy/confound_check.sh, run before the deploy that shipped while this post was being written

That is the whole gate. It diffs what prod runs against what I am about to deploy, refuses if the change touches any file on the registered core list, and prints the six environment variables that have no diff to catch them so I check those by hand. A clean result still gets a disclosure comment on both tracking issues, because a gate you only report when it fires is a gate nobody can audit.

I could go and work out today whether it is working. That is precisely why I am not going to.

What I hope for

Not a fortune. If this thing produces a durable edge, it will be small, it will be seasonal, and it will decay when the pattern gets crowded. Anyone promising otherwise is selling something.

What I want is a calibrated answer to a real question. Does public news carry exploitable drift in the ring around the company it is about, after the primary has already repriced, and can a mechanical system capture enough of it to matter after costs?

If the answer turns out to be no, I want to be able to say so with a number attached, and to know the answer is trustworthy because I wrote down what I expected before I looked. That is a genuinely good outcome. It is a question closed honestly, which is more than most of this genre ever produces.

If the answer is yes, then the non-goals list already says what happens next: four to eight weeks of paper evidence before a single real dollar, then options and faster feeds only if the ledger proves they are the binding constraint, and then only as additional strategies running alongside, never replacements, so the comparison never gets destroyed.

And underneath both branches there is a second thing I am testing, which may outlast the trading question entirely: whether a system that critiques itself daily, predicts the effect of its own changes in advance, and is graded by a process that did not write those predictions can actually improve without a human inventing the improvements. The trading is the substrate. The loop is the experiment.

When I was a teenage developer I dreamed about a system that improved itself. The technology is here. Let's see if we know how to use it.

If you want to check any of this

The chart above is not a screenshot of a data provider. It is drawn from my tests/fixtures/avgo-2026-07-06/, the same four JSON files the design document's evidence table was computed from, committed to the repository in July and unchanged since. That is deliberate: a chart and a table that disagree mean one of them was made up, and these two cannot disagree, because they read the same bytes.

The numbers in that table reconcile against the daily bars the system stores for itself. AVGO closed 6 July at 373.90 against a previous close of 360.45, which is +3.7%. AMD closed at 552.05 against 517.82, which is +6.6%, and from the 12:26 detection price of 538.10 that is +2.6%. Those are the two figures the whole thesis rests on.

The status page carries the live version of everything described here, including the change history and the decision journal, and it stays public through the grading window.

Go and look

The status page is public, and it stays public during the window. Go and look. Every position, every alert, the decision journal, the change history. If it is a quiet day the tables will be short, which is also the honest version of what this is.

What is sealed is not the dashboard, it is the comparison. I am not allowed to evaluate the experiment early, or to let an interim read change what I ship while the window is open. You are under no such restriction, which is a slightly strange thing to write and exactly the point: the record is open even to the parts I am holding myself back from grading. Click into a weekly review and read what the system concluded about its own week, in its own words, unedited. There is a history page with a daily scorecard.

The source code is not public. So when I use the word verifiable, I mean the record is open, not the implementation. I would rather say that plainly than let a good word do work it has not earned.

Around 25 September I will publish the result of the experiment that is running now. If it failed, that post will say it failed, and this sentence exists so I cannot quietly not write it.

Come back in a month and hold me to it.

Lucian Daniliuc

Lisbon, Portugal

Life enthusiast, entrepreneur, señor dev, passionate photographer, lover, runner, dancer, traveler, good listener, and terrible singer.