feat: add autonomous paper-trading and calibration pipeline
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const { initAutonomySchema } = require('../src/autonomy/schema');
|
||||
const { enqueueJob, leaseNextJob, completeJob } = require('../src/autonomy/jobs');
|
||||
const { normalizeProposal, acceptProposal } = require('../src/autonomy/coordinator');
|
||||
const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration');
|
||||
const { decide } = require('../src/autonomy/policy');
|
||||
const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution');
|
||||
const { calculateOutcome } = require('../src/autonomy/outcomes');
|
||||
const { createOrderIntent } = require('../src/autonomy/orderIntents');
|
||||
const { reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
|
||||
|
||||
test('autonomy schema and leased jobs are restart-safe', () => {
|
||||
const db = new Database(':memory:');
|
||||
initAutonomySchema(db);
|
||||
assert.equal(enqueueJob(db, {
|
||||
jobType: 'enrich', lane: 'live', priority: 10, entityType: 'article', entityId: 42,
|
||||
idempotencyKey: 'enrich:42',
|
||||
}).inserted, true);
|
||||
assert.equal(enqueueJob(db, {
|
||||
jobType: 'enrich', lane: 'live', priority: 10, entityType: 'article', entityId: 42,
|
||||
idempotencyKey: 'enrich:42',
|
||||
}).inserted, false);
|
||||
const job = leaseNextJob(db, 'test-worker');
|
||||
assert.equal(job.lane, 'live');
|
||||
assert.equal(completeJob(db, job.id, 'test-worker'), true);
|
||||
assert.equal(db.prepare("SELECT status FROM autonomy_jobs WHERE id = ?").get(job.id).status, 'complete');
|
||||
});
|
||||
|
||||
test('coordinator proposals require evidence and contain no arbitrary numeric confidence', () => {
|
||||
const normalized = normalizeProposal({ predictions: [{
|
||||
instrument: 'nvda', direction: 'positive', event_type: 'supply_constraint',
|
||||
horizon_days: 10, evidence_article_ids: [7],
|
||||
}] }, { informationCutoff: '2026-01-01T00:00:00Z', model: 'test-model' });
|
||||
assert.equal(normalized.predictions[0].instrument, 'NVDA');
|
||||
assert.equal('probability' in normalized.predictions[0], false);
|
||||
assert.throws(() => normalizeProposal({ predictions: [{ instrument: 'NVDA', direction: 'positive', horizon_days: 10 }] }));
|
||||
});
|
||||
|
||||
test('accepted proposal preserves evidence and creates immutable prediction', () => {
|
||||
const archive = new Database(':memory:');
|
||||
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY)');
|
||||
archive.prepare('INSERT INTO articles (id) VALUES (?)').run(7);
|
||||
const intelligence = new Database(':memory:');
|
||||
initAutonomySchema(intelligence);
|
||||
intelligence.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA', 'test', 1, 1)").run();
|
||||
const result = acceptProposal(intelligence, archive, {
|
||||
predictions: [{ instrument: 'NVDA', direction: 'positive', event_type: 'earnings', horizon_days: 10, evidence_article_ids: [7] }],
|
||||
}, { informationCutoff: '2026-01-01T00:00:00Z', strategyVersion: 'test' });
|
||||
assert.equal(result.predictionCount, 1);
|
||||
assert.deepEqual(JSON.parse(intelligence.prepare('SELECT evidence_article_ids FROM autonomy_predictions').get().evidence_article_ids), [7]);
|
||||
});
|
||||
|
||||
test('calibration and policy abstain on insufficient evidence', () => {
|
||||
const calibration = calibrateOutcomes([
|
||||
{ excess_return: 0.02, direction_correct: 1 },
|
||||
{ excess_return: -0.01, direction_correct: 0 },
|
||||
]);
|
||||
assert.equal(calibration.sampleSize, 2);
|
||||
const result = decide({ ...calibration }, { minSampleSize: 30 });
|
||||
assert.equal(result.action, 'ABSTAIN');
|
||||
assert.equal(cohortKey({ sector: 'tech', eventType: 'earnings', horizonDays: 10, direction: 'positive' }), 'tech|earnings|10|positive');
|
||||
assert.equal(decide({ direction: 'negative', probability: 0.8, expectedExcessReturn: -0.02, lowerReturn: -0.04, sampleSize: 40 }).action, 'SELL');
|
||||
});
|
||||
|
||||
test('paper execution is allowlisted, bounded and idempotent', () => {
|
||||
const intent = validatePaperIntent({ decisionId: 12, instrument: 'NVDA', action: 'BUY', notional: 100 }, { tradable: true, maxNotional: 500 });
|
||||
const broker = createSimulator();
|
||||
assert.deepEqual(broker.submit(intent), broker.submit(intent));
|
||||
assert.throws(() => validatePaperIntent({ decisionId: 13, instrument: 'PRIVATE', action: 'BUY', notional: 100 }, { tradable: false }));
|
||||
assert.throws(() => validatePaperIntent({ decisionId: 14, instrument: 'NVDA', action: 'BUY', notional: 501 }, { tradable: true, maxNotional: 500 }));
|
||||
});
|
||||
|
||||
test('archive reconciliation prioritizes recent usable events and is bounded', () => {
|
||||
const archive = new Database(':memory:');
|
||||
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY, event_id INTEGER, ingested_at TEXT, content TEXT, has_embedding INTEGER)');
|
||||
archive.prepare('INSERT INTO articles VALUES (1, 99, ?, ?, 1)').run(new Date().toISOString(), 'content');
|
||||
const intelligence = new Database(':memory:');
|
||||
initAutonomySchema(intelligence);
|
||||
const result = reconcileArchiveBatch(archive, intelligence, 1);
|
||||
assert.equal(result.scanned, 1);
|
||||
const job = intelligence.prepare("SELECT lane, priority FROM autonomy_jobs WHERE job_type='coordinator_event'").get();
|
||||
assert.equal(job.lane, 'live');
|
||||
assert.equal(job.priority, 100);
|
||||
const live = reconcileLiveBatch(archive, intelligence, 1);
|
||||
assert.equal(live.scanned, 1);
|
||||
});
|
||||
|
||||
test('outcome calculation uses benchmark-relative return', () => {
|
||||
const outcome = calculateOutcome(
|
||||
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 5, direction: 'positive' },
|
||||
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-09', close: 110 }],
|
||||
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-09', close: 105 }]
|
||||
);
|
||||
assert.equal(outcome.directionCorrect, 1);
|
||||
assert.equal(outcome.excessReturn, 0.05);
|
||||
});
|
||||
|
||||
test('order intents require the explicit instrument allowlist', () => {
|
||||
const db = new Database(':memory:');
|
||||
initAutonomySchema(db);
|
||||
db.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA', 'sim', 1, 1)").run();
|
||||
const proposal = db.prepare(`INSERT INTO autonomy_proposals(payload, information_cutoff, status) VALUES ('{}', datetime('now'), 'accepted')`).run();
|
||||
const prediction = db.prepare(`INSERT INTO autonomy_predictions(proposal_id, instrument, direction, event_type, horizon_days, information_cutoff, evidence_article_ids, learning_eligible, strategy_version) VALUES (?, 'NVDA', 'positive', 'test', 10, datetime('now'), '[1]', 1, 'test')`).run(proposal.lastInsertRowid);
|
||||
const decision = db.prepare(`INSERT INTO autonomy_decisions(prediction_id, action, rationale, strategy_version) VALUES (?, 'BUY', 'test', 'test')`).run(prediction.lastInsertRowid);
|
||||
const intent = createOrderIntent(db, decision.lastInsertRowid, 100, { maxNotional: 100 });
|
||||
assert.equal(intent.side, 'buy');
|
||||
assert.equal(db.prepare('SELECT status FROM autonomy_order_intents').get().status, 'shadow');
|
||||
});
|
||||
Reference in New Issue
Block a user